Skip to main content

nextest_runner/build_scope/
scope.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use super::PackageSpec;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8/// A build scope for Cargo.
9///
10/// For more information, see [_Build scope
11/// arguments_](https://nexte.st/docs/design/architecture/recording-runs/#build-scope-arguments)
12/// on the nextest site.
13// NOTE: Unknown fields are ignored on read. If you add a new field, ensure that
14// RUNS_JSON_FORMAT_VERSION and STORE_FORMAT_VERSION (minor version) are both
15// bumped.
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "kebab-case")]
18pub struct BuildScope {
19    #[serde(
20        default = "PackageSelection::empty",
21        skip_serializing_if = "PackageSelection::is_empty"
22    )]
23    packages: PackageSelection,
24    #[serde(
25        default = "TargetSelection::empty",
26        skip_serializing_if = "TargetSelection::is_empty"
27    )]
28    targets: TargetSelection,
29    #[serde(
30        default = "FeatureSelection::empty",
31        skip_serializing_if = "FeatureSelection::is_empty"
32    )]
33    features: FeatureSelection,
34}
35
36impl BuildScope {
37    /// Returns a new [`BuildScope`] with the given package, target, and feature
38    /// selections.
39    pub fn new(
40        packages: PackageSelection,
41        targets: TargetSelection,
42        features: FeatureSelection,
43    ) -> Self {
44        Self {
45            packages,
46            targets,
47            features,
48        }
49    }
50
51    /// Returns the package selection for this scope.
52    pub fn packages(&self) -> &PackageSelection {
53        &self.packages
54    }
55
56    /// Returns the target selection for this scope.
57    pub fn targets(&self) -> &TargetSelection {
58        &self.targets
59    }
60
61    /// Returns the feature selection for this scope.
62    pub fn features(&self) -> &FeatureSelection {
63        &self.features
64    }
65
66    /// Returns `true` if this scope is empty, i.e. no package, target, or
67    /// feature selections.
68    ///
69    /// This results in Cargo's default scope for the current directory.
70    pub fn is_empty(&self) -> bool {
71        let Self {
72            packages,
73            targets,
74            features,
75        } = self;
76        packages.is_empty() && targets.is_empty() && features.is_empty()
77    }
78
79    /// Returns flags to provide to Cargo.
80    pub fn cargo_flags(&self) -> Vec<CargoFlag<'_>> {
81        let Self {
82            packages,
83            targets,
84            features,
85        } = self;
86
87        let mut flags = Vec::new();
88        packages.push_cargo_flags(&mut flags);
89        targets.push_cargo_flags(&mut flags);
90        features.push_cargo_flags(&mut flags);
91        flags
92    }
93
94    /// Returns the set of arguments to pass to Cargo.
95    ///
96    /// Flags with values are always returned as a single `--flag=value`
97    /// argument, so that values starting with `-` aren't interpreted as flags.
98    pub fn to_cargo_args(&self) -> Vec<String> {
99        self.cargo_flags()
100            .into_iter()
101            .map(|flag| flag.to_cargo_arg())
102            .collect()
103    }
104}
105
106impl fmt::Display for BuildScope {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        for (index, flag) in self.cargo_flags().into_iter().enumerate() {
109            if index > 0 {
110                f.write_str(" ")?;
111            }
112            match flag {
113                CargoFlag::Bare(flag) => f.write_str(flag)?,
114                CargoFlag::Valued { flag, value } => {
115                    // Quote the value for friendlier user display. Flag names
116                    // never need to be quoted since they don't have embedded
117                    // spaces or special characters.
118                    write!(f, "{flag}={}", shell_words::quote(value))?
119                }
120            }
121        }
122        Ok(())
123    }
124}
125
126/// A single Cargo flag, either with or without a value.
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub enum CargoFlag<'a> {
129    /// A bare flag without a value, e.g. `--workspace`.
130    Bare(&'static str),
131    /// A valued flag with a value, e.g. `--package=foo`.
132    Valued {
133        /// The flag name, e.g. `--package`.
134        flag: &'static str,
135        /// The flag value, e.g. `foo`.
136        value: &'a str,
137    },
138}
139
140impl CargoFlag<'_> {
141    /// Converts this flag to a Cargo argument string, e.g. `--package=foo`.
142    pub fn to_cargo_arg(&self) -> String {
143        match self {
144            Self::Bare(flag) => (*flag).to_owned(),
145            // We shouldn't quote the value here, because this is passed into the
146            // subprocess's argv directly without going through a shell.
147            Self::Valued { flag, value } => format!("{flag}={value}"),
148        }
149    }
150}
151
152/// Package selection in a build scope.
153///
154/// The deprecated `--all` is not represented here; it is equivalent to
155/// `workspace`, and is folded while converting from the CLI arguments.
156#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "kebab-case")]
158pub struct PackageSelection {
159    /// The package specs to include in the build.
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub specs: Vec<PackageSpec>,
162    /// Whether to include the entire workspace in the build.
163    #[serde(default, skip_serializing_if = "is_false")]
164    pub workspace: bool,
165    /// The package specs to exclude from the build.
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub exclude: Vec<PackageSpec>,
168}
169
170impl PackageSelection {
171    fn empty() -> Self {
172        Self {
173            specs: Vec::new(),
174            workspace: false,
175            exclude: Vec::new(),
176        }
177    }
178
179    /// Returns `true` if this package selection is empty.
180    pub fn is_empty(&self) -> bool {
181        let Self {
182            specs,
183            workspace,
184            exclude,
185        } = self;
186        specs.is_empty() && !*workspace && exclude.is_empty()
187    }
188
189    fn push_cargo_flags<'a>(&'a self, flags: &mut Vec<CargoFlag<'a>>) {
190        let Self {
191            specs,
192            workspace,
193            exclude,
194        } = self;
195
196        for spec in specs {
197            flags.push(CargoFlag::Valued {
198                flag: "--package",
199                value: spec.as_str(),
200            });
201        }
202        if *workspace {
203            flags.push(CargoFlag::Bare("--workspace"));
204        }
205        for spec in exclude {
206            flags.push(CargoFlag::Valued {
207                flag: "--exclude",
208                value: spec.as_str(),
209            });
210        }
211    }
212}
213
214/// Target selection in a build scope.
215#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "kebab-case")]
217pub struct TargetSelection {
218    /// Whether to include the library target in the build.
219    #[serde(default, skip_serializing_if = "is_false")]
220    pub lib: bool,
221
222    /// The binary targets to include in the build.
223    #[serde(
224        default = "NamedTargets::empty",
225        skip_serializing_if = "NamedTargets::is_empty"
226    )]
227    pub bins: NamedTargets,
228
229    /// The example targets to include in the build.
230    #[serde(
231        default = "NamedTargets::empty",
232        skip_serializing_if = "NamedTargets::is_empty"
233    )]
234    pub examples: NamedTargets,
235
236    /// The test targets to include in the build.
237    #[serde(
238        default = "NamedTargets::empty",
239        skip_serializing_if = "NamedTargets::is_empty"
240    )]
241    pub tests: NamedTargets,
242
243    /// The benchmark targets to include in the build.
244    #[serde(
245        default = "NamedTargets::empty",
246        skip_serializing_if = "NamedTargets::is_empty"
247    )]
248    pub benches: NamedTargets,
249
250    /// Whether to include all targets in the build.
251    #[serde(default, skip_serializing_if = "is_false")]
252    pub all_targets: bool,
253}
254
255impl TargetSelection {
256    fn empty() -> Self {
257        Self {
258            lib: false,
259            bins: NamedTargets::empty(),
260            examples: NamedTargets::empty(),
261            tests: NamedTargets::empty(),
262            benches: NamedTargets::empty(),
263            all_targets: false,
264        }
265    }
266
267    /// Returns `true` if this target selection is empty.
268    pub fn is_empty(&self) -> bool {
269        let Self {
270            lib,
271            bins,
272            examples,
273            tests,
274            benches,
275            all_targets,
276        } = self;
277        !*lib
278            && bins.is_empty()
279            && examples.is_empty()
280            && tests.is_empty()
281            && benches.is_empty()
282            && !*all_targets
283    }
284
285    fn push_cargo_flags<'a>(&'a self, flags: &mut Vec<CargoFlag<'a>>) {
286        let Self {
287            lib,
288            bins,
289            examples,
290            tests,
291            benches,
292            all_targets,
293        } = self;
294
295        if *lib {
296            flags.push(CargoFlag::Bare("--lib"));
297        }
298        bins.push_cargo_flags(flags, "--bin", "--bins");
299        examples.push_cargo_flags(flags, "--example", "--examples");
300        tests.push_cargo_flags(flags, "--test", "--tests");
301        benches.push_cargo_flags(flags, "--bench", "--benches");
302        if *all_targets {
303            flags.push(CargoFlag::Bare("--all-targets"));
304        }
305    }
306}
307
308/// A named target selection in a build scope, for `--bin`/`--bins`,
309/// `--example`/`--examples`, `--test`/`--tests`, and `--bench`/`--benches`.
310#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "kebab-case")]
312pub struct NamedTargets {
313    /// The names of the targets to include, e.g. `--bin=NAME`.
314    #[serde(default, skip_serializing_if = "Vec::is_empty")]
315    pub names: Vec<String>,
316
317    /// Whether to include all targets of this kind, e.g. `--bins`.
318    #[serde(default, skip_serializing_if = "is_false")]
319    pub all: bool,
320}
321
322impl NamedTargets {
323    pub(crate) fn empty() -> Self {
324        Self {
325            names: Vec::new(),
326            all: false,
327        }
328    }
329
330    /// Returns `true` if this target selection is empty.
331    pub fn is_empty(&self) -> bool {
332        let Self { names, all } = self;
333        names.is_empty() && !*all
334    }
335
336    fn push_cargo_flags<'a>(
337        &'a self,
338        flags: &mut Vec<CargoFlag<'a>>,
339        name_flag: &'static str,
340        all_flag: &'static str,
341    ) {
342        let Self { names, all } = self;
343
344        for name in names {
345            flags.push(CargoFlag::Valued {
346                flag: name_flag,
347                value: name,
348            });
349        }
350        if *all {
351            flags.push(CargoFlag::Bare(all_flag));
352        }
353    }
354}
355
356/// A selection of features to include in a build scope.
357#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(rename_all = "kebab-case")]
359pub struct FeatureSelection {
360    /// The features to include, e.g. `--feature=NAME`.
361    #[serde(default, skip_serializing_if = "Vec::is_empty")]
362    pub features: Vec<String>,
363    /// Whether to include all features: `--all-features`.
364    #[serde(default, skip_serializing_if = "is_false")]
365    pub all_features: bool,
366    /// Whether to exclude default features: `--no-default-features`.
367    #[serde(default, skip_serializing_if = "is_false")]
368    pub no_default_features: bool,
369}
370
371impl FeatureSelection {
372    fn empty() -> Self {
373        Self {
374            features: Vec::new(),
375            all_features: false,
376            no_default_features: false,
377        }
378    }
379
380    /// Returns `true` if this feature selection is empty.
381    pub fn is_empty(&self) -> bool {
382        let Self {
383            features,
384            all_features,
385            no_default_features,
386        } = self;
387        features.is_empty() && !*all_features && !*no_default_features
388    }
389
390    fn push_cargo_flags<'a>(&'a self, flags: &mut Vec<CargoFlag<'a>>) {
391        let Self {
392            features,
393            all_features,
394            no_default_features,
395        } = self;
396
397        for feature in features {
398            flags.push(CargoFlag::Valued {
399                flag: "--features",
400                value: feature,
401            });
402        }
403        if *all_features {
404            flags.push(CargoFlag::Bare("--all-features"));
405        }
406        if *no_default_features {
407            flags.push(CargoFlag::Bare("--no-default-features"));
408        }
409    }
410}
411
412fn is_false(value: &bool) -> bool {
413    !*value
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::build_scope::test_helpers::arb_valid_package_spec;
420    use proptest::{collection::vec, prelude::*};
421    use test_strategy::proptest;
422
423    fn spec(input: &str) -> PackageSpec {
424        PackageSpec::new(input).expect("spec is valid")
425    }
426
427    fn named(names: &[&str], all: bool) -> NamedTargets {
428        NamedTargets {
429            names: names.iter().map(|name| (*name).to_owned()).collect(),
430            all,
431        }
432    }
433
434    fn populated_scope() -> BuildScope {
435        BuildScope::new(
436            PackageSelection {
437                specs: vec![spec("foo"), spec("foo@1.2"), spec("nextest-*")],
438                workspace: true,
439                exclude: vec![spec("bar"), spec("baz@0.1.0")],
440            },
441            TargetSelection {
442                lib: true,
443                bins: named(&["my-bin", "bin with space", "back\\slash"], true),
444                examples: named(&["example", ""], true),
445                tests: named(&["integration", "-dash-test", "line\nbreak"], true),
446                benches: named(&["bench"], true),
447                all_targets: true,
448            },
449            FeatureSelection {
450                features: vec![
451                    "feat-a".to_owned(),
452                    "feat-b feat-c".to_owned(),
453                    "-feat-d,feat-e".to_owned(),
454                    "it's".to_owned(),
455                ],
456                all_features: true,
457                no_default_features: true,
458            },
459        )
460    }
461
462    fn empty_scope() -> BuildScope {
463        BuildScope::new(
464            PackageSelection::empty(),
465            TargetSelection::empty(),
466            FeatureSelection::empty(),
467        )
468    }
469
470    #[test]
471    fn serialize_populated() {
472        let scope = populated_scope();
473        assert!(!scope.is_empty());
474        let json = serde_json::to_string_pretty(&scope).expect("serialized scope");
475        insta::assert_snapshot!("serialize_populated", json);
476
477        let json = serde_json::to_string_pretty(&empty_scope()).expect("serialized empty scope");
478        insta::assert_snapshot!(json, @"{}");
479
480        // Unknown fields are ignored on read.
481        let deserialized: BuildScope = serde_json::from_str(
482            r#"{"packages": {"specs": ["foo"], "future": 1}, "future": true}"#,
483        )
484        .expect("scope with unknown fields deserializes");
485        assert_eq!(
486            deserialized,
487            BuildScope::new(
488                PackageSelection {
489                    specs: vec![spec("foo")],
490                    workspace: false,
491                    exclude: Vec::new(),
492                },
493                TargetSelection::empty(),
494                FeatureSelection::empty(),
495            )
496        );
497    }
498
499    #[test]
500    fn cargo_args_and_display() {
501        let scope = populated_scope();
502        let args =
503            serde_json::to_string_pretty(&scope.to_cargo_args()).expect("serialized cargo args");
504        insta::assert_snapshot!("cargo_args_populated", args);
505        insta::assert_snapshot!("display_populated", scope.to_string());
506    }
507
508    fn arb_named_targets() -> impl Strategy<Value = NamedTargets> {
509        let non_empty = (vec(any::<String>(), 0..3), any::<bool>())
510            .prop_map(|(names, all)| NamedTargets { names, all });
511        prop_oneof![1 => Just(NamedTargets::empty()), 2 => non_empty]
512    }
513
514    fn arb_build_scope() -> impl Strategy<Value = BuildScope> {
515        let packages = (
516            vec(arb_valid_package_spec(), 0..3),
517            any::<bool>(),
518            vec(arb_valid_package_spec(), 0..3),
519        )
520            .prop_map(|(specs, workspace, exclude)| PackageSelection {
521                specs,
522                workspace,
523                exclude,
524            });
525        let targets = (
526            any::<bool>(),
527            arb_named_targets(),
528            arb_named_targets(),
529            arb_named_targets(),
530            arb_named_targets(),
531            any::<bool>(),
532        )
533            .prop_map(|(lib, bins, examples, tests, benches, all_targets)| {
534                TargetSelection {
535                    lib,
536                    bins,
537                    examples,
538                    tests,
539                    benches,
540                    all_targets,
541                }
542            });
543        let features = (vec(any::<String>(), 0..3), any::<bool>(), any::<bool>()).prop_map(
544            |(features, all_features, no_default_features)| FeatureSelection {
545                features,
546                all_features,
547                no_default_features,
548            },
549        );
550        (
551            prop_oneof![1 => Just(PackageSelection::empty()), 2 => packages],
552            prop_oneof![1 => Just(TargetSelection::empty()), 2 => targets],
553            prop_oneof![1 => Just(FeatureSelection::empty()), 2 => features],
554        )
555            .prop_map(|(packages, targets, features)| BuildScope::new(packages, targets, features))
556    }
557
558    #[proptest]
559    fn serde_round_trip(#[strategy(arb_build_scope())] scope: BuildScope) {
560        let json = serde_json::to_string(&scope).expect("serialized scope");
561        prop_assert_eq!(scope.is_empty(), json == "{}");
562        let deserialized: BuildScope = serde_json::from_str(&json).expect("deserialized scope");
563        prop_assert_eq!(&deserialized, &scope);
564        prop_assert_eq!(scope.is_empty(), scope.to_cargo_args().is_empty());
565        let split = shell_words::split(&scope.to_string()).expect("display output splits");
566        prop_assert_eq!(split, scope.to_cargo_args());
567    }
568}