Skip to main content

cargo_feature_combinations/
cli.rs

1//! CLI argument parsing, options, and help text.
2
3use color_eyre::eyre::{self, WrapErr};
4use std::collections::HashSet;
5use std::path::PathBuf;
6
7/// High-level command requested by the user.
8#[derive(Debug)]
9pub enum Command {
10    /// Print a JSON feature matrix to stdout.
11    ///
12    /// The matrix is produced by combining [`crate::Package::feature_matrix`]
13    /// for all selected packages into a single JSON array.
14    FeatureMatrix {
15        /// Whether to pretty-print the JSON feature matrix.
16        pretty: bool,
17    },
18    /// Print the tool version and exit.
19    Version,
20    /// Print help text and exit.
21    Help,
22}
23
24/// Command-line options recognized by this crate.
25///
26/// Instances of this type are produced by [`parse_arguments`] and consumed by
27/// [`crate::run`] to drive command selection and filtering.
28#[derive(Debug, Default)]
29#[allow(clippy::struct_excessive_bools)]
30pub struct Options {
31    /// Optional path to the Cargo manifest that should be inspected.
32    pub manifest_path: Option<PathBuf>,
33    /// Explicit list of package names to include.
34    pub packages: HashSet<String>,
35    /// List of package names to exclude.
36    pub exclude_packages: HashSet<String>,
37    /// High-level command to execute.
38    pub command: Option<Command>,
39    /// Whether to restrict processing to packages with a library target.
40    pub only_packages_with_lib_target: bool,
41    /// Whether to hide cargo output and only show the final summary.
42    ///
43    /// Set by `--summary-only` or its backward-compatible aliases `--summary`
44    /// and `--silent`.
45    pub summary_only: bool,
46    /// Whether to show only diagnostics (warnings/errors) per feature
47    /// combination, suppressing compilation progress noise.
48    ///
49    /// Set by `--diagnostics-only`.
50    pub diagnostics_only: bool,
51    /// Whether to deduplicate diagnostics across feature combinations.
52    ///
53    /// Implies `--diagnostics-only`. Identical diagnostics are printed only
54    /// once; the summary reports how many were suppressed.
55    pub dedupe: bool,
56    /// Whether to print more verbose information such as the full cargo command.
57    pub verbose: bool,
58    /// Whether to treat warnings like errors for the summary and `--fail-fast`.
59    pub pedantic: bool,
60    /// Whether to silence warnings from rustc and only show errors.
61    pub errors_only: bool,
62    /// Whether to only list packages instead of all feature combinations.
63    pub packages_only: bool,
64    /// Whether to stop processing after the first failing feature combination.
65    pub fail_fast: bool,
66    /// Whether to disable automatic pruning of implied feature combinations.
67    ///
68    /// Set by `--no-prune-implied`.
69    pub no_prune_implied: bool,
70    /// Whether to show pruned feature combinations in the summary.
71    ///
72    /// Set by `--show-pruned`.
73    pub show_pruned: bool,
74}
75
76/// Helper trait to provide simple argument parsing over `Vec<String>`.
77pub trait ArgumentParser {
78    /// Check whether an argument flag exists, either as a standalone flag or
79    /// in `--flag=value` form.
80    fn contains(&self, arg: &str) -> bool;
81    /// Extract all occurrences of an argument and their values.
82    ///
83    /// When `has_value` is `true`, this matches `--flag value` and
84    /// `--flag=value` forms and returns the value part. When `has_value` is
85    /// `false`, it matches bare flags like `--flag`.
86    fn get_all(&self, arg: &str, has_value: bool)
87    -> Vec<(std::ops::RangeInclusive<usize>, String)>;
88}
89
90impl ArgumentParser for Vec<String> {
91    fn contains(&self, arg: &str) -> bool {
92        self.iter()
93            .any(|a| a == arg || a.starts_with(&format!("{arg}=")))
94    }
95
96    fn get_all(
97        &self,
98        arg: &str,
99        has_value: bool,
100    ) -> Vec<(std::ops::RangeInclusive<usize>, String)> {
101        let mut matched = Vec::new();
102        for (idx, a) in self.iter().enumerate() {
103            match (a, self.get(idx + 1)) {
104                (key, Some(value)) if key == arg && has_value => {
105                    matched.push((idx..=idx + 1, value.clone()));
106                }
107                (key, _) if key == arg && !has_value => {
108                    matched.push((idx..=idx, key.clone()));
109                }
110                (key, _) if key.starts_with(&format!("{arg}=")) => {
111                    let value = key.trim_start_matches(&format!("{arg}="));
112                    matched.push((idx..=idx, value.to_string()));
113                }
114                _ => {}
115            }
116        }
117        matched.reverse();
118        matched
119    }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
123pub(crate) enum CargoSubcommand {
124    Build,
125    Check,
126    Test,
127    Doc,
128    Run,
129    Other,
130}
131
132/// Determine the cargo subcommand implied by the argument list.
133pub(crate) fn cargo_subcommand(args: &[impl AsRef<str>]) -> CargoSubcommand {
134    let args: HashSet<&str> = args.iter().map(AsRef::as_ref).collect();
135    if args.contains("build") || args.contains("b") {
136        CargoSubcommand::Build
137    } else if args.contains("check") || args.contains("c") || args.contains("clippy") {
138        CargoSubcommand::Check
139    } else if args.contains("test") || args.contains("t") {
140        CargoSubcommand::Test
141    } else if args.contains("doc") || args.contains("d") {
142        CargoSubcommand::Doc
143    } else if args.contains("run") || args.contains("r") {
144        CargoSubcommand::Run
145    } else {
146        CargoSubcommand::Other
147    }
148}
149
150static VALID_BOOLS: [&str; 4] = ["yes", "true", "y", "t"];
151
152const HELP_TEXT: &str = r#"Run cargo commands for all feature combinations
153
154USAGE:
155    cargo fc [+toolchain] [SUBCOMMAND] [SUBCOMMAND_OPTIONS]
156    cargo fc [+toolchain] [OPTIONS] [CARGO_OPTIONS] [CARGO_SUBCOMMAND]
157
158SUBCOMMAND:
159    matrix                  Print JSON feature combination matrix to stdout
160        --pretty            Print pretty JSON
161
162OPTIONS:
163    --help                  Print help information
164    --diagnostics-only      Show only diagnostics (warnings/errors) per
165                            feature combination, suppressing build noise
166    --dedupe                Like --diagnostics-only, but also deduplicate
167                            identical diagnostics across feature combinations
168    --summary-only          Hide cargo output and only show the final summary
169    --fail-fast             Fail fast on the first bad feature combination
170    --errors-only           Allow all warnings, show errors only (-Awarnings)
171    --exclude-package       Exclude a package from feature combinations
172    --only-packages-with-lib-target
173                            Only consider packages with a library target
174    --pedantic              Treat warnings like errors in summary and
175                            when using --fail-fast
176    --no-prune-implied      Disable automatic pruning of redundant feature
177                            combinations implied by other features
178    --show-pruned           Show pruned feature combinations in the summary
179
180Feature sets can be configured in your Cargo.toml configuration.
181The following metadata key aliases are all supported:
182
183    [package.metadata.cargo-fc]            (recommended)
184    [package.metadata.fc]
185    [package.metadata.cargo-feature-combinations]
186    [package.metadata.feature-combinations]
187
188For example:
189
190```toml
191[package.metadata.cargo-fc]
192
193# Exclude groupings of features that are incompatible or do not make sense
194exclude_feature_sets = [ ["foo", "bar"], ] # formerly "skip_feature_sets"
195
196# To exclude only the empty feature set from the matrix, you can either enable
197# `no_empty_feature_set = true` or explicitly list an empty set here:
198#
199# exclude_feature_sets = [[]]
200
201# Exclude features from the feature combination matrix
202exclude_features = ["default", "full"] # formerly "denylist"
203
204# Include features in the feature combination matrix
205#
206# These features will be added to every generated feature combination.
207# This does not restrict which features are varied for the combinatorial
208# matrix. To restrict the matrix to a specific allowlist of features, use
209# `only_features`.
210include_features = ["feature-that-must-always-be-set"]
211
212# Only consider these features when generating the combinatorial matrix.
213#
214# When set, features not listed here are ignored for the combinatorial matrix.
215# When empty, all package features are considered.
216only_features = ["default", "full"]
217
218# Skip implicit features that correspond to optional dependencies from the
219# matrix.
220#
221# When enabled, the implicit features that Cargo generates for optional
222# dependencies (of the form `foo = ["dep:foo"]` in the feature graph) are
223# removed from the combinatorial matrix. This mirrors the behaviour of the
224# `skip_optional_dependencies` flag in the `cargo-all-features` crate.
225skip_optional_dependencies = true
226
227# In the end, always add these exact combinations to the overall feature matrix,
228# unless one is already present there.
229#
230# Non-existent features are ignored. Other configuration options are ignored.
231include_feature_sets = [
232    ["foo-a", "bar-a", "other-a"],
233] # formerly "exact_combinations"
234
235# Allow only the listed feature sets.
236#
237# When this list is non-empty, the feature matrix will consist exactly of the
238# configured sets (after dropping non-existent features). No powerset is
239# generated.
240allow_feature_sets = [
241    ["hydrate"],
242    ["ssr"],
243]
244
245# When enabled, never include the empty feature set (no `--features`), even if
246# it would otherwise be generated.
247no_empty_feature_set = true
248
249# Automatically prune redundant feature combinations whose resolved feature
250# set (after Cargo's feature unification) matches a smaller combination.
251# Enabled by default. Disable with `prune_implied = false`.
252# prune_implied = true
253
254# When at least one isolated feature set is configured, stop taking all project
255# features as a whole, and instead take them in these isolated sets. Build a
256# sub-matrix for each isolated set, then merge sub-matrices into the overall
257# feature matrix. If any two isolated sets produce an identical feature
258# combination, such combination will be included in the overall matrix only once.
259#
260# This feature is intended for projects with large number of features, sub-sets
261# of which are completely independent, and thus don't need cross-play.
262#
263# Other configuration options are still respected.
264isolated_feature_sets = [
265    ["foo-a", "foo-b", "foo-c"],
266    ["bar-a", "bar-b"],
267    ["other-a", "other-b", "other-c"],
268]
269```
270
271Target-specific configuration can be expressed via Cargo-style `cfg(...)` selectors:
272
273```toml
274[package.metadata.cargo-fc]
275exclude_features = ["default"]
276
277[package.metadata.cargo-fc.target.'cfg(target_os = "linux")']
278exclude_features = { add = ["metal"] }
279```
280
281Notes:
282
283- Arrays in target overrides are always treated as overrides.
284  Use `{ add = [...] }` / `{ remove = [...] }` for additive changes.
285- Patches are applied in order: override (or base), then remove, then add.
286  If a value appears in both `add` and `remove`, add wins.
287- When multiple sections match, their `add`/`remove` sets are unioned.
288  Conflicting `override` values result in an error.
289- `replace = true` starts from a fresh default config for that target.
290  When `replace = true` is set, patchable fields must not use `add`/`remove`.
291- `cfg(feature = "...")` predicates are not supported in target override keys.
292- If `--target <triple>` or `CARGO_BUILD_TARGET` is set, it is used to select
293  matching target overrides (this also applies to `cargo fc matrix`).
294
295When using a cargo workspace, you can also exclude packages in your workspace `Cargo.toml`:
296
297```toml
298[workspace.metadata.cargo-fc]
299# Exclude packages in the workspace metadata, or the metadata of the *root* package.
300exclude_packages = ["package-a", "package-b"]
301```
302
303For more information, see 'https://github.com/romnn/cargo-feature-combinations'.
304
305See 'cargo help <command>' for more information on a specific command.
306"#;
307
308/// Print the help text to stdout.
309pub(crate) fn print_help() {
310    println!("{HELP_TEXT}");
311}
312
313/// Parse command-line arguments for the `cargo-*` binary.
314///
315/// The returned [`Options`] drives workspace discovery and filtering, while
316/// the remaining `Vec<String>` contains the raw cargo arguments.
317///
318/// # Errors
319///
320/// Returns an error if the manifest path passed via `--manifest-path` does
321/// not exist or can not be canonicalized.
322pub fn parse_arguments(bin_name: &str) -> eyre::Result<(Options, Vec<String>)> {
323    let mut args: Vec<String> = std::env::args_os()
324        // Skip executable name
325        .skip(1)
326        // Skip our own cargo-* command name
327        .skip_while(|arg| {
328            let arg = arg.as_os_str();
329            arg == bin_name || arg == "cargo"
330        })
331        .map(|s| s.to_string_lossy().to_string())
332        .collect();
333
334    let mut options = Options {
335        verbose: VALID_BOOLS.contains(
336            &std::env::var("VERBOSE")
337                .unwrap_or_default()
338                .to_lowercase()
339                .as_str(),
340        ),
341        ..Options::default()
342    };
343
344    // Extract path to manifest to operate on
345    for (span, manifest_path) in args.get_all("--manifest-path", true) {
346        let manifest_path = PathBuf::from(manifest_path);
347        let manifest_path = manifest_path
348            .canonicalize()
349            .wrap_err_with(|| format!("manifest {} does not exist", manifest_path.display()))?;
350        options.manifest_path = Some(manifest_path);
351        args.drain(span);
352    }
353
354    // Extract packages to operate on
355    for flag in ["--package", "-p"] {
356        for (span, package) in args.get_all(flag, true) {
357            options.packages.insert(package);
358            args.drain(span);
359        }
360    }
361
362    for (span, package) in args.get_all("--exclude-package", true) {
363        options.exclude_packages.insert(package.trim().to_string());
364        args.drain(span);
365    }
366
367    for (span, _) in args.get_all("--only-packages-with-lib-target", false) {
368        options.only_packages_with_lib_target = true;
369        args.drain(span);
370    }
371
372    // Check for matrix command
373    for (span, _) in args.get_all("matrix", false) {
374        options.command = Some(Command::FeatureMatrix { pretty: false });
375        args.drain(span);
376    }
377    // Check for pretty matrix option
378    for (span, _) in args.get_all("--pretty", false) {
379        if let Some(Command::FeatureMatrix { ref mut pretty }) = options.command {
380            *pretty = true;
381        }
382        args.drain(span);
383    }
384
385    // Check for help command
386    for (span, _) in args.get_all("--help", false) {
387        options.command = Some(Command::Help);
388        args.drain(span);
389    }
390
391    // Check for version flag
392    for (span, _) in args.get_all("--version", false) {
393        options.command = Some(Command::Version);
394        args.drain(span);
395    }
396
397    // Check for version command
398    for (span, _) in args.get_all("version", false) {
399        options.command = Some(Command::Version);
400        args.drain(span);
401    }
402
403    let mut drain_flag = |flag: &str, field: &mut bool| {
404        for (span, _) in args.get_all(flag, false) {
405            *field = true;
406            args.drain(span);
407        }
408    };
409    drain_flag("--pedantic", &mut options.pedantic);
410    drain_flag("--errors-only", &mut options.errors_only);
411    drain_flag("--packages-only", &mut options.packages_only);
412    drain_flag("--diagnostics-only", &mut options.diagnostics_only);
413    drain_flag("--fail-fast", &mut options.fail_fast);
414    drain_flag("--no-prune-implied", &mut options.no_prune_implied);
415    drain_flag("--show-pruned", &mut options.show_pruned);
416
417    // --dedupe implies --diagnostics-only
418    for flag in ["--dedupe", "--dedup"] {
419        for (span, _) in args.get_all(flag, false) {
420            options.dedupe = true;
421            options.diagnostics_only = true;
422            args.drain(span);
423        }
424    }
425
426    // --summary-only aliases
427    for flag in ["--summary-only", "--summary", "--silent"] {
428        for (span, _) in args.get_all(flag, false) {
429            options.summary_only = true;
430            args.drain(span);
431        }
432    }
433
434    // Ignore `--workspace`. This tool already discovers the relevant workspace
435    // packages via `cargo metadata` and then runs cargo separately in each
436    // package's directory. Forwarding `--workspace` to those per-package
437    // invocations would re-enable workspace-level feature application and can
438    // cause spurious errors when some workspace members do not define a
439    // particular feature.
440    for (span, _) in args.get_all("--workspace", false) {
441        args.drain(span);
442    }
443
444    Ok((options, args))
445}