nextest_runner/config/
scripts.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Setup scripts.
5
6use super::{
7    ConfigIdentifier, EvaluatableProfile, FinalConfig, LeakTimeout, MaybeTargetSpec,
8    PlatformStrings, PreBuildPlatform, SlowTimeout,
9};
10use crate::{
11    double_spawn::{DoubleSpawnContext, DoubleSpawnInfo},
12    errors::{
13        ChildStartError, ConfigCompileError, ConfigCompileErrorKind, ConfigCompileSection,
14        InvalidConfigScriptName,
15    },
16    helpers::convert_rel_path_to_main_sep,
17    list::TestList,
18    platform::BuildPlatforms,
19    reporter::events::SetupScriptEnvMap,
20    test_command::{apply_ld_dyld_env, create_command},
21};
22use camino::Utf8Path;
23use camino_tempfile::Utf8TempPath;
24use guppy::graph::cargo::BuildPlatform;
25use iddqd::{IdOrdItem, id_upcast};
26use indexmap::IndexMap;
27use nextest_filtering::{
28    BinaryQuery, EvalContext, Filterset, FiltersetKind, ParseContext, TestQuery,
29};
30use serde::{Deserialize, de::Error};
31use smol_str::SmolStr;
32use std::{
33    collections::{HashMap, HashSet},
34    fmt,
35    process::Command,
36    sync::Arc,
37};
38
39/// The scripts defined in nextest configuration.
40#[derive(Clone, Debug, Default, Deserialize)]
41#[serde(rename_all = "kebab-case")]
42pub struct ScriptConfig {
43    // These maps are ordered because scripts are used in the order they're defined.
44    /// The setup scripts defined in nextest's configuration.
45    #[serde(default)]
46    pub setup: IndexMap<ScriptId, SetupScriptConfig>,
47    /// The wrapper scripts defined in nextest's configuration.
48    #[serde(default)]
49    pub wrapper: IndexMap<ScriptId, WrapperScriptConfig>,
50}
51
52impl ScriptConfig {
53    pub(super) fn is_empty(&self) -> bool {
54        self.setup.is_empty() && self.wrapper.is_empty()
55    }
56
57    /// Returns information about the script with the given ID.
58    ///
59    /// Panics if the ID is invalid.
60    pub(super) fn script_info(&self, id: ScriptId) -> ScriptInfo {
61        let script_type = if self.setup.contains_key(&id) {
62            ScriptType::Setup
63        } else if self.wrapper.contains_key(&id) {
64            ScriptType::Wrapper
65        } else {
66            panic!("ScriptConfig::script_info called with invalid script ID: {id}")
67        };
68
69        ScriptInfo {
70            id: id.clone(),
71            script_type,
72        }
73    }
74
75    /// Returns an iterator over the names of all scripts of all types.
76    pub(super) fn all_script_ids(&self) -> impl Iterator<Item = &ScriptId> {
77        self.setup.keys().chain(self.wrapper.keys())
78    }
79
80    /// Returns an iterator over names that are used by more than one type of
81    /// script.
82    pub(super) fn duplicate_ids(&self) -> impl Iterator<Item = &ScriptId> {
83        self.wrapper.keys().filter(|k| self.setup.contains_key(*k))
84    }
85}
86
87/// Basic information about a script, used during error checking.
88#[derive(Clone, Debug)]
89pub struct ScriptInfo {
90    /// The script ID.
91    pub id: ScriptId,
92
93    /// The type of the script.
94    pub script_type: ScriptType,
95}
96
97impl IdOrdItem for ScriptInfo {
98    type Key<'a> = &'a ScriptId;
99    fn key(&self) -> Self::Key<'_> {
100        &self.id
101    }
102    id_upcast!();
103}
104
105/// The script type as configured in the `[scripts]` table.
106#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, PartialOrd, Ord)]
107pub enum ScriptType {
108    /// A setup script.
109    Setup,
110
111    /// A wrapper script.
112    Wrapper,
113}
114
115impl ScriptType {
116    pub(super) fn matches(self, profile_script_type: ProfileScriptType) -> bool {
117        match self {
118            ScriptType::Setup => profile_script_type == ProfileScriptType::Setup,
119            ScriptType::Wrapper => {
120                profile_script_type == ProfileScriptType::ListWrapper
121                    || profile_script_type == ProfileScriptType::RunWrapper
122            }
123        }
124    }
125}
126
127impl fmt::Display for ScriptType {
128    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
129        match self {
130            ScriptType::Setup => f.write_str("setup"),
131            ScriptType::Wrapper => f.write_str("wrapper"),
132        }
133    }
134}
135
136/// A script type as configured in `[[profile.*.scripts]]`.
137#[derive(Clone, Copy, Debug, Eq, PartialEq)]
138pub enum ProfileScriptType {
139    /// A setup script.
140    Setup,
141
142    /// A list-time wrapper script.
143    ListWrapper,
144
145    /// A run-time wrapper script.
146    RunWrapper,
147}
148
149impl fmt::Display for ProfileScriptType {
150    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
151        match self {
152            ProfileScriptType::Setup => f.write_str("setup"),
153            ProfileScriptType::ListWrapper => f.write_str("list-wrapper"),
154            ProfileScriptType::RunWrapper => f.write_str("run-wrapper"),
155        }
156    }
157}
158
159/// Data about setup scripts, returned by an [`EvaluatableProfile`].
160pub struct SetupScripts<'profile> {
161    enabled_scripts: IndexMap<&'profile ScriptId, SetupScript<'profile>>,
162}
163
164impl<'profile> SetupScripts<'profile> {
165    pub(super) fn new(profile: &'profile EvaluatableProfile<'_>, test_list: &TestList<'_>) -> Self {
166        Self::new_with_queries(
167            profile,
168            test_list
169                .iter_tests()
170                .filter(|test| test.test_info.filter_match.is_match())
171                .map(|test| test.to_test_query()),
172        )
173    }
174
175    // Creates a new `SetupScripts` instance for the given profile and matching tests.
176    fn new_with_queries<'a>(
177        profile: &'profile EvaluatableProfile<'_>,
178        matching_tests: impl IntoIterator<Item = TestQuery<'a>>,
179    ) -> Self {
180        let script_config = profile.script_config();
181        let profile_scripts = &profile.compiled_data.scripts;
182        if profile_scripts.is_empty() {
183            return Self {
184                enabled_scripts: IndexMap::new(),
185            };
186        }
187
188        // Build a map of setup scripts to the test configurations that enable them.
189        let mut by_script_id = HashMap::new();
190        for profile_script in profile_scripts {
191            for script_id in &profile_script.setup {
192                by_script_id
193                    .entry(script_id)
194                    .or_insert_with(Vec::new)
195                    .push(profile_script);
196            }
197        }
198
199        let env = profile.filterset_ecx();
200
201        // This is a map from enabled setup scripts to a list of configurations that enabled them.
202        let mut enabled_ids = HashSet::new();
203        for test in matching_tests {
204            // Look at all the setup scripts activated by this test.
205            for (&script_id, compiled) in &by_script_id {
206                if enabled_ids.contains(script_id) {
207                    // This script is already enabled.
208                    continue;
209                }
210                if compiled.iter().any(|data| data.is_enabled(&test, &env)) {
211                    enabled_ids.insert(script_id);
212                }
213            }
214        }
215
216        // Build up a map of enabled scripts along with their data, by script ID.
217        let mut enabled_scripts = IndexMap::new();
218        for (script_id, config) in &script_config.setup {
219            if enabled_ids.contains(script_id) {
220                let compiled = by_script_id
221                    .remove(script_id)
222                    .expect("script id must be present");
223                enabled_scripts.insert(
224                    script_id,
225                    SetupScript {
226                        id: script_id.clone(),
227                        config,
228                        compiled,
229                    },
230                );
231            }
232        }
233
234        Self { enabled_scripts }
235    }
236
237    /// Returns the number of enabled setup scripts.
238    #[inline]
239    pub fn len(&self) -> usize {
240        self.enabled_scripts.len()
241    }
242
243    /// Returns true if there are no enabled setup scripts.
244    #[inline]
245    pub fn is_empty(&self) -> bool {
246        self.enabled_scripts.is_empty()
247    }
248
249    /// Returns enabled setup scripts in the order they should be run in.
250    #[inline]
251    pub(crate) fn into_iter(self) -> impl Iterator<Item = SetupScript<'profile>> {
252        self.enabled_scripts.into_values()
253    }
254}
255
256/// Data about an individual setup script.
257///
258/// Returned by [`SetupScripts::iter`].
259#[derive(Clone, Debug)]
260#[non_exhaustive]
261pub(crate) struct SetupScript<'profile> {
262    /// The script ID.
263    pub(crate) id: ScriptId,
264
265    /// The configuration for the script.
266    pub(crate) config: &'profile SetupScriptConfig,
267
268    /// The compiled filters to use to check which tests this script is enabled for.
269    pub(crate) compiled: Vec<&'profile CompiledProfileScripts<FinalConfig>>,
270}
271
272impl SetupScript<'_> {
273    pub(crate) fn is_enabled(&self, test: &TestQuery<'_>, cx: &EvalContext<'_>) -> bool {
274        self.compiled
275            .iter()
276            .any(|compiled| compiled.is_enabled(test, cx))
277    }
278}
279
280/// Represents a to-be-run setup script command with a certain set of arguments.
281pub(crate) struct SetupScriptCommand {
282    /// The command to be run.
283    command: std::process::Command,
284    /// The environment file.
285    env_path: Utf8TempPath,
286    /// Double-spawn context.
287    double_spawn: Option<DoubleSpawnContext>,
288}
289
290impl SetupScriptCommand {
291    /// Creates a new `SetupScriptCommand` for a setup script.
292    pub(crate) fn new(
293        config: &SetupScriptConfig,
294        profile_name: &str,
295        double_spawn: &DoubleSpawnInfo,
296        test_list: &TestList<'_>,
297    ) -> Result<Self, ChildStartError> {
298        let mut cmd = create_command(
299            config
300                .command
301                .program(&test_list.rust_build_meta().target_directory),
302            &config.command.args,
303            double_spawn,
304        );
305
306        // NB: we will always override user-provided environment variables with the
307        // `CARGO_*` and `NEXTEST_*` variables set directly on `cmd` below.
308        test_list.cargo_env().apply_env(&mut cmd);
309
310        let env_path = camino_tempfile::Builder::new()
311            .prefix("nextest-env")
312            .tempfile()
313            .map_err(|error| ChildStartError::TempPath(Arc::new(error)))?
314            .into_temp_path();
315
316        cmd.current_dir(test_list.workspace_root())
317            // This environment variable is set to indicate that tests are being run under nextest.
318            .env("NEXTEST", "1")
319            // Set the nextest profile.
320            .env("NEXTEST_PROFILE", profile_name)
321            // Setup scripts can define environment variables which are written out here.
322            .env("NEXTEST_ENV", &env_path);
323
324        apply_ld_dyld_env(&mut cmd, test_list.updated_dylib_path());
325
326        let double_spawn = double_spawn.spawn_context();
327
328        Ok(Self {
329            command: cmd,
330            env_path,
331            double_spawn,
332        })
333    }
334
335    /// Returns the command to be run.
336    #[inline]
337    pub(crate) fn command_mut(&mut self) -> &mut std::process::Command {
338        &mut self.command
339    }
340
341    pub(crate) fn spawn(self) -> std::io::Result<(tokio::process::Child, Utf8TempPath)> {
342        let mut command = tokio::process::Command::from(self.command);
343        let res = command.spawn();
344        if let Some(ctx) = self.double_spawn {
345            ctx.finish();
346        }
347        let child = res?;
348        Ok((child, self.env_path))
349    }
350}
351
352/// Data obtained by executing setup scripts. This is used to set up the environment for tests.
353#[derive(Clone, Debug, Default)]
354pub(crate) struct SetupScriptExecuteData<'profile> {
355    env_maps: Vec<(SetupScript<'profile>, SetupScriptEnvMap)>,
356}
357
358impl<'profile> SetupScriptExecuteData<'profile> {
359    pub(crate) fn new() -> Self {
360        Self::default()
361    }
362
363    pub(crate) fn add_script(&mut self, script: SetupScript<'profile>, env_map: SetupScriptEnvMap) {
364        self.env_maps.push((script, env_map));
365    }
366
367    /// Applies the data from setup scripts to the given test instance.
368    pub(crate) fn apply(&self, test: &TestQuery<'_>, cx: &EvalContext<'_>, command: &mut Command) {
369        for (script, env_map) in &self.env_maps {
370            if script.is_enabled(test, cx) {
371                for (key, value) in env_map.env_map.iter() {
372                    command.env(key, value);
373                }
374            }
375        }
376    }
377}
378
379#[derive(Clone, Debug)]
380pub(crate) struct CompiledProfileScripts<State> {
381    pub(super) setup: Vec<ScriptId>,
382    pub(super) list_wrapper: Option<ScriptId>,
383    pub(super) run_wrapper: Option<ScriptId>,
384    pub(super) data: ProfileScriptData,
385    pub(super) state: State,
386}
387
388impl CompiledProfileScripts<PreBuildPlatform> {
389    pub(super) fn new(
390        pcx: &ParseContext<'_>,
391        profile_name: &str,
392        index: usize,
393        source: &DeserializedProfileScriptConfig,
394        errors: &mut Vec<ConfigCompileError>,
395    ) -> Option<Self> {
396        if source.platform.host.is_none()
397            && source.platform.target.is_none()
398            && source.filter.is_none()
399        {
400            errors.push(ConfigCompileError {
401                profile_name: profile_name.to_owned(),
402                section: ConfigCompileSection::Script(index),
403                kind: ConfigCompileErrorKind::ConstraintsNotSpecified {
404                    // The default filter is not relevant for scripts -- it is a
405                    // configuration value, not a constraint.
406                    default_filter_specified: false,
407                },
408            });
409            return None;
410        }
411
412        let host_spec = MaybeTargetSpec::new(source.platform.host.as_deref());
413        let target_spec = MaybeTargetSpec::new(source.platform.target.as_deref());
414
415        let filter_expr = source.filter.as_ref().map_or(Ok(None), |filter| {
416            // TODO: probably want to restrict the set of expressions here via
417            // the `kind` parameter.
418            Some(Filterset::parse(
419                filter.clone(),
420                pcx,
421                FiltersetKind::DefaultFilter,
422            ))
423            .transpose()
424        });
425
426        match (host_spec, target_spec, filter_expr) {
427            (Ok(host_spec), Ok(target_spec), Ok(expr)) => Some(Self {
428                setup: source.setup.clone(),
429                list_wrapper: source.list_wrapper.clone(),
430                run_wrapper: source.run_wrapper.clone(),
431                data: ProfileScriptData {
432                    host_spec,
433                    target_spec,
434                    expr,
435                },
436                state: PreBuildPlatform {},
437            }),
438            (maybe_host_err, maybe_platform_err, maybe_parse_err) => {
439                let host_platform_parse_error = maybe_host_err.err();
440                let platform_parse_error = maybe_platform_err.err();
441                let parse_errors = maybe_parse_err.err();
442
443                errors.push(ConfigCompileError {
444                    profile_name: profile_name.to_owned(),
445                    section: ConfigCompileSection::Script(index),
446                    kind: ConfigCompileErrorKind::Parse {
447                        host_parse_error: host_platform_parse_error,
448                        target_parse_error: platform_parse_error,
449                        filter_parse_errors: parse_errors.into_iter().collect(),
450                    },
451                });
452                None
453            }
454        }
455    }
456
457    pub(super) fn apply_build_platforms(
458        self,
459        build_platforms: &BuildPlatforms,
460    ) -> CompiledProfileScripts<FinalConfig> {
461        let host_eval = self.data.host_spec.eval(&build_platforms.host.platform);
462        let host_test_eval = self.data.target_spec.eval(&build_platforms.host.platform);
463        let target_eval = build_platforms
464            .target
465            .as_ref()
466            .map_or(host_test_eval, |target| {
467                self.data.target_spec.eval(&target.triple.platform)
468            });
469
470        CompiledProfileScripts {
471            setup: self.setup,
472            list_wrapper: self.list_wrapper,
473            run_wrapper: self.run_wrapper,
474            data: self.data,
475            state: FinalConfig {
476                host_eval,
477                host_test_eval,
478                target_eval,
479            },
480        }
481    }
482}
483
484impl CompiledProfileScripts<FinalConfig> {
485    pub(super) fn is_enabled_binary(
486        &self,
487        query: &BinaryQuery<'_>,
488        cx: &EvalContext<'_>,
489    ) -> Option<bool> {
490        if !self.state.host_eval {
491            return Some(false);
492        }
493        if query.platform == BuildPlatform::Host && !self.state.host_test_eval {
494            return Some(false);
495        }
496        if query.platform == BuildPlatform::Target && !self.state.target_eval {
497            return Some(false);
498        }
499
500        if let Some(expr) = &self.data.expr {
501            expr.matches_binary(query, cx)
502        } else {
503            Some(true)
504        }
505    }
506
507    pub(super) fn is_enabled(&self, query: &TestQuery<'_>, cx: &EvalContext<'_>) -> bool {
508        if !self.state.host_eval {
509            return false;
510        }
511        if query.binary_query.platform == BuildPlatform::Host && !self.state.host_test_eval {
512            return false;
513        }
514        if query.binary_query.platform == BuildPlatform::Target && !self.state.target_eval {
515            return false;
516        }
517
518        if let Some(expr) = &self.data.expr {
519            expr.matches_test(query, cx)
520        } else {
521            true
522        }
523    }
524}
525
526/// The name of a configuration script.
527#[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord)]
528pub struct ScriptId(pub ConfigIdentifier);
529
530impl ScriptId {
531    /// Creates a new script identifier.
532    pub fn new(identifier: SmolStr) -> Result<Self, InvalidConfigScriptName> {
533        let identifier = ConfigIdentifier::new(identifier).map_err(InvalidConfigScriptName)?;
534        Ok(Self(identifier))
535    }
536
537    /// Returns the name of the script as a [`ConfigIdentifier`].
538    pub fn as_identifier(&self) -> &ConfigIdentifier {
539        &self.0
540    }
541
542    #[cfg(test)]
543    pub(super) fn as_str(&self) -> &str {
544        self.0.as_str()
545    }
546}
547
548impl<'de> Deserialize<'de> for ScriptId {
549    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
550    where
551        D: serde::Deserializer<'de>,
552    {
553        // Try and deserialize as a string.
554        let identifier = SmolStr::deserialize(deserializer)?;
555        Self::new(identifier).map_err(serde::de::Error::custom)
556    }
557}
558
559impl fmt::Display for ScriptId {
560    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
561        write!(f, "{}", self.0)
562    }
563}
564
565#[derive(Clone, Debug)]
566pub(super) struct ProfileScriptData {
567    host_spec: MaybeTargetSpec,
568    target_spec: MaybeTargetSpec,
569    expr: Option<Filterset>,
570}
571
572impl ProfileScriptData {
573    pub(super) fn expr(&self) -> Option<&Filterset> {
574        self.expr.as_ref()
575    }
576}
577
578/// Deserialized form of profile-specific script configuration before compilation.
579#[derive(Clone, Debug, Deserialize)]
580#[serde(rename_all = "kebab-case")]
581pub(super) struct DeserializedProfileScriptConfig {
582    /// The host and/or target platforms to match against.
583    #[serde(default)]
584    pub(super) platform: PlatformStrings,
585
586    /// The filterset to match against.
587    #[serde(default)]
588    filter: Option<String>,
589
590    /// The setup script or scripts to run.
591    #[serde(default, deserialize_with = "deserialize_script_ids")]
592    setup: Vec<ScriptId>,
593
594    /// The wrapper script to run at list time.
595    #[serde(default)]
596    list_wrapper: Option<ScriptId>,
597
598    /// The wrapper script to run at run time.
599    #[serde(default)]
600    run_wrapper: Option<ScriptId>,
601}
602
603/// Deserialized form of setup script configuration before compilation.
604///
605/// This is defined as a top-level element.
606#[derive(Clone, Debug, Deserialize)]
607#[serde(rename_all = "kebab-case")]
608pub struct SetupScriptConfig {
609    /// The command to run. The first element is the program and the second element is a list
610    /// of arguments.
611    pub command: ScriptCommand,
612
613    /// An optional slow timeout for this command.
614    #[serde(default, deserialize_with = "super::deserialize_slow_timeout")]
615    pub slow_timeout: Option<SlowTimeout>,
616
617    /// An optional leak timeout for this command.
618    #[serde(default, deserialize_with = "super::deserialize_leak_timeout")]
619    pub leak_timeout: Option<LeakTimeout>,
620
621    /// Whether to capture standard output for this command.
622    #[serde(default)]
623    pub capture_stdout: bool,
624
625    /// Whether to capture standard error for this command.
626    #[serde(default)]
627    pub capture_stderr: bool,
628
629    /// JUnit configuration for this script.
630    #[serde(default)]
631    pub junit: SetupScriptJunitConfig,
632}
633
634impl SetupScriptConfig {
635    /// Returns true if at least some output isn't being captured.
636    #[inline]
637    pub fn no_capture(&self) -> bool {
638        !(self.capture_stdout && self.capture_stderr)
639    }
640}
641
642/// A JUnit override configuration.
643#[derive(Copy, Clone, Debug, Deserialize)]
644#[serde(rename_all = "kebab-case")]
645pub struct SetupScriptJunitConfig {
646    /// Whether to store successful output.
647    ///
648    /// Defaults to true.
649    #[serde(default = "default_true")]
650    pub store_success_output: bool,
651
652    /// Whether to store failing output.
653    ///
654    /// Defaults to true.
655    #[serde(default = "default_true")]
656    pub store_failure_output: bool,
657}
658
659impl Default for SetupScriptJunitConfig {
660    fn default() -> Self {
661        Self {
662            store_success_output: true,
663            store_failure_output: true,
664        }
665    }
666}
667
668/// Deserialized form of wrapper script configuration before compilation.
669///
670/// This is defined as a top-level element.
671#[derive(Clone, Debug, Deserialize)]
672#[serde(rename_all = "kebab-case")]
673pub struct WrapperScriptConfig {
674    /// The command to run.
675    pub command: ScriptCommand,
676
677    /// How this script interacts with a configured target runner, if any.
678    /// Defaults to ignoring the target runner.
679    #[serde(default)]
680    pub target_runner: WrapperScriptTargetRunner,
681}
682
683/// Interaction of wrapper script with a configured target runner.
684#[derive(Clone, Debug, Default)]
685pub enum WrapperScriptTargetRunner {
686    /// The target runner is ignored. This is the default.
687    #[default]
688    Ignore,
689
690    /// The target runner overrides the wrapper.
691    OverridesWrapper,
692
693    /// The target runner runs within the wrapper script. The command line used
694    /// is `<wrapper> <target-runner> <test-binary> <args>`.
695    WithinWrapper,
696
697    /// The target runner runs around the wrapper script. The command line used
698    /// is `<target-runner> <wrapper> <test-binary> <args>`.
699    AroundWrapper,
700}
701
702impl<'de> Deserialize<'de> for WrapperScriptTargetRunner {
703    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
704    where
705        D: serde::Deserializer<'de>,
706    {
707        let s = String::deserialize(deserializer)?;
708        match s.as_str() {
709            "ignore" => Ok(WrapperScriptTargetRunner::Ignore),
710            "overrides-wrapper" => Ok(WrapperScriptTargetRunner::OverridesWrapper),
711            "within-wrapper" => Ok(WrapperScriptTargetRunner::WithinWrapper),
712            "around-wrapper" => Ok(WrapperScriptTargetRunner::AroundWrapper),
713            _ => Err(serde::de::Error::unknown_variant(
714                &s,
715                &[
716                    "ignore",
717                    "overrides-wrapper",
718                    "within-wrapper",
719                    "around-wrapper",
720                ],
721            )),
722        }
723    }
724}
725
726fn default_true() -> bool {
727    true
728}
729
730fn deserialize_script_ids<'de, D>(deserializer: D) -> Result<Vec<ScriptId>, D::Error>
731where
732    D: serde::Deserializer<'de>,
733{
734    struct ScriptIdVisitor;
735
736    impl<'de> serde::de::Visitor<'de> for ScriptIdVisitor {
737        type Value = Vec<ScriptId>;
738
739        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
740            formatter.write_str("a script ID (string) or a list of script IDs")
741        }
742
743        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
744        where
745            E: serde::de::Error,
746        {
747            Ok(vec![ScriptId::new(value.into()).map_err(E::custom)?])
748        }
749
750        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
751        where
752            A: serde::de::SeqAccess<'de>,
753        {
754            let mut ids = Vec::new();
755            while let Some(value) = seq.next_element::<String>()? {
756                ids.push(ScriptId::new(value.into()).map_err(A::Error::custom)?);
757            }
758            Ok(ids)
759        }
760    }
761
762    deserializer.deserialize_any(ScriptIdVisitor)
763}
764
765/// The script command to run.
766#[derive(Clone, Debug)]
767pub struct ScriptCommand {
768    /// The program to run.
769    pub program: String,
770
771    /// The arguments to pass to the program.
772    pub args: Vec<String>,
773
774    /// Which directory to interpret the program as relative to.
775    ///
776    /// This controls just how `program` is interpreted, in case it is a
777    /// relative path.
778    pub relative_to: ScriptCommandRelativeTo,
779}
780
781impl ScriptCommand {
782    /// Returns the program to run, resolved with respect to the target directory.
783    pub fn program(&self, target_dir: &Utf8Path) -> String {
784        match self.relative_to {
785            ScriptCommandRelativeTo::None => self.program.clone(),
786            ScriptCommandRelativeTo::Target => {
787                // If the path is relative, convert it to the main separator.
788                let path = Utf8Path::new(&self.program);
789                if path.is_relative() {
790                    target_dir
791                        .join(convert_rel_path_to_main_sep(path))
792                        .to_string()
793                } else {
794                    path.to_string()
795                }
796            }
797        }
798    }
799}
800
801impl<'de> Deserialize<'de> for ScriptCommand {
802    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
803    where
804        D: serde::Deserializer<'de>,
805    {
806        struct CommandVisitor;
807
808        impl<'de> serde::de::Visitor<'de> for CommandVisitor {
809            type Value = ScriptCommand;
810
811            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
812                formatter.write_str("a Unix shell command, a list of arguments, or a table with command-line and relative-to")
813            }
814
815            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
816            where
817                E: serde::de::Error,
818            {
819                let mut args = shell_words::split(value).map_err(E::custom)?;
820                if args.is_empty() {
821                    return Err(E::invalid_value(serde::de::Unexpected::Str(value), &self));
822                }
823                let program = args.remove(0);
824                Ok(ScriptCommand {
825                    program,
826                    args,
827                    relative_to: ScriptCommandRelativeTo::None,
828                })
829            }
830
831            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
832            where
833                A: serde::de::SeqAccess<'de>,
834            {
835                let Some(program) = seq.next_element::<String>()? else {
836                    return Err(A::Error::invalid_length(0, &self));
837                };
838                let mut args = Vec::new();
839                while let Some(value) = seq.next_element::<String>()? {
840                    args.push(value);
841                }
842                Ok(ScriptCommand {
843                    program,
844                    args,
845                    relative_to: ScriptCommandRelativeTo::None,
846                })
847            }
848
849            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
850            where
851                A: serde::de::MapAccess<'de>,
852            {
853                let mut command_line = None;
854                let mut relative_to = None;
855
856                while let Some(key) = map.next_key::<String>()? {
857                    match key.as_str() {
858                        "command-line" => {
859                            if command_line.is_some() {
860                                return Err(A::Error::duplicate_field("command-line"));
861                            }
862                            command_line = Some(map.next_value_seed(CommandInnerSeed)?);
863                        }
864                        "relative-to" => {
865                            if relative_to.is_some() {
866                                return Err(A::Error::duplicate_field("relative-to"));
867                            }
868                            relative_to = Some(map.next_value::<ScriptCommandRelativeTo>()?);
869                        }
870                        _ => {
871                            return Err(A::Error::unknown_field(
872                                &key,
873                                &["command-line", "relative-to"],
874                            ));
875                        }
876                    }
877                }
878
879                let (program, arguments) =
880                    command_line.ok_or_else(|| A::Error::missing_field("command-line"))?;
881                let relative_to = relative_to.unwrap_or(ScriptCommandRelativeTo::None);
882
883                Ok(ScriptCommand {
884                    program,
885                    args: arguments,
886                    relative_to,
887                })
888            }
889        }
890
891        deserializer.deserialize_any(CommandVisitor)
892    }
893}
894
895struct CommandInnerSeed;
896
897impl<'de> serde::de::DeserializeSeed<'de> for CommandInnerSeed {
898    type Value = (String, Vec<String>);
899
900    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
901    where
902        D: serde::Deserializer<'de>,
903    {
904        struct CommandInnerVisitor;
905
906        impl<'de> serde::de::Visitor<'de> for CommandInnerVisitor {
907            type Value = (String, Vec<String>);
908
909            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
910                formatter.write_str("a string or array of strings")
911            }
912
913            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
914            where
915                E: serde::de::Error,
916            {
917                let mut args = shell_words::split(value).map_err(E::custom)?;
918                if args.is_empty() {
919                    return Err(E::invalid_value(
920                        serde::de::Unexpected::Str(value),
921                        &"a non-empty command string",
922                    ));
923                }
924                let program = args.remove(0);
925                Ok((program, args))
926            }
927
928            fn visit_seq<S>(self, mut seq: S) -> Result<Self::Value, S::Error>
929            where
930                S: serde::de::SeqAccess<'de>,
931            {
932                let mut args = Vec::new();
933                while let Some(value) = seq.next_element::<String>()? {
934                    args.push(value);
935                }
936                if args.is_empty() {
937                    return Err(S::Error::invalid_length(0, &self));
938                }
939                let program = args.remove(0);
940                Ok((program, args))
941            }
942        }
943
944        deserializer.deserialize_any(CommandInnerVisitor)
945    }
946}
947
948/// The directory to interpret a [`ScriptCommand`] as relative to, in case it is
949/// a relative path.
950///
951/// If specified, the program will be joined with the provided path.
952#[derive(Clone, Copy, Debug)]
953pub enum ScriptCommandRelativeTo {
954    /// Do not join the program with any path.
955    None,
956
957    /// Join the program with the target directory.
958    Target,
959    // TODO: TargetProfile, similar to ArchiveRelativeTo
960}
961
962impl<'de> Deserialize<'de> for ScriptCommandRelativeTo {
963    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
964    where
965        D: serde::Deserializer<'de>,
966    {
967        let s = String::deserialize(deserializer)?;
968        match s.as_str() {
969            "none" => Ok(ScriptCommandRelativeTo::None),
970            "target" => Ok(ScriptCommandRelativeTo::Target),
971            _ => Err(serde::de::Error::unknown_variant(&s, &["none", "target"])),
972        }
973    }
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979    use crate::{
980        config::{ConfigExperimental, NextestConfig, ToolConfigFile, test_helpers::*},
981        errors::{
982            ConfigParseErrorKind, DisplayErrorChain, ProfileListScriptUsesRunFiltersError,
983            ProfileScriptErrors, ProfileUnknownScriptError, ProfileWrongConfigScriptTypeError,
984        },
985    };
986    use camino_tempfile::tempdir;
987    use camino_tempfile_ext::prelude::*;
988    use indoc::indoc;
989    use maplit::btreeset;
990    use test_case::test_case;
991
992    #[test]
993    fn test_scripts_basic() {
994        let config_contents = indoc! {r#"
995            [[profile.default.scripts]]
996            platform = { host = "x86_64-unknown-linux-gnu" }
997            filter = "test(script1)"
998            setup = ["foo", "bar"]
999
1000            [[profile.default.scripts]]
1001            platform = { target = "aarch64-apple-darwin" }
1002            filter = "test(script2)"
1003            setup = "baz"
1004
1005            [[profile.default.scripts]]
1006            filter = "test(script3)"
1007            # No matter which order scripts are specified here, they must always be run in the
1008            # order defined below.
1009            setup = ["baz", "foo", "@tool:my-tool:toolscript"]
1010
1011            [scripts.setup.foo]
1012            command = "command foo"
1013
1014            [scripts.setup.bar]
1015            command = ["cargo", "run", "-p", "bar"]
1016            slow-timeout = { period = "60s", terminate-after = 2 }
1017
1018            [scripts.setup.baz]
1019            command = "baz"
1020            slow-timeout = "1s"
1021            leak-timeout = "1s"
1022            capture-stdout = true
1023            capture-stderr = true
1024        "#
1025        };
1026
1027        let tool_config_contents = indoc! {r#"
1028            [scripts.setup.'@tool:my-tool:toolscript']
1029            command = "tool-command"
1030            "#
1031        };
1032
1033        let workspace_dir = tempdir().unwrap();
1034
1035        let graph = temp_workspace(&workspace_dir, config_contents);
1036        let tool_path = workspace_dir.child(".config/my-tool.toml");
1037        tool_path.write_str(tool_config_contents).unwrap();
1038
1039        let package_id = graph.workspace().iter().next().unwrap().id();
1040
1041        let pcx = ParseContext::new(&graph);
1042
1043        let tool_config_files = [ToolConfigFile {
1044            tool: "my-tool".to_owned(),
1045            config_file: tool_path.to_path_buf(),
1046        }];
1047
1048        // First, check that if the experimental feature isn't enabled, we get an error.
1049        let nextest_config_error = NextestConfig::from_sources(
1050            graph.workspace().root(),
1051            &pcx,
1052            None,
1053            &tool_config_files,
1054            &Default::default(),
1055        )
1056        .unwrap_err();
1057        match nextest_config_error.kind() {
1058            ConfigParseErrorKind::ExperimentalFeaturesNotEnabled { missing_features } => {
1059                assert_eq!(
1060                    *missing_features,
1061                    btreeset! { ConfigExperimental::SetupScripts }
1062                );
1063            }
1064            other => panic!("unexpected error kind: {other:?}"),
1065        }
1066
1067        // Now, check with the experimental feature enabled.
1068        let nextest_config_result = NextestConfig::from_sources(
1069            graph.workspace().root(),
1070            &pcx,
1071            None,
1072            &tool_config_files,
1073            &btreeset! { ConfigExperimental::SetupScripts },
1074        )
1075        .expect("config is valid");
1076        let profile = nextest_config_result
1077            .profile("default")
1078            .expect("valid profile name")
1079            .apply_build_platforms(&build_platforms());
1080
1081        // This query matches the foo and bar scripts.
1082        let host_binary_query =
1083            binary_query(&graph, package_id, "lib", "my-binary", BuildPlatform::Host);
1084        let query = TestQuery {
1085            binary_query: host_binary_query.to_query(),
1086            test_name: "script1",
1087        };
1088        let scripts = SetupScripts::new_with_queries(&profile, std::iter::once(query));
1089        assert_eq!(scripts.len(), 2, "two scripts should be enabled");
1090        assert_eq!(
1091            scripts.enabled_scripts.get_index(0).unwrap().0.as_str(),
1092            "foo",
1093            "first script should be foo"
1094        );
1095        assert_eq!(
1096            scripts.enabled_scripts.get_index(1).unwrap().0.as_str(),
1097            "bar",
1098            "second script should be bar"
1099        );
1100
1101        let target_binary_query = binary_query(
1102            &graph,
1103            package_id,
1104            "lib",
1105            "my-binary",
1106            BuildPlatform::Target,
1107        );
1108
1109        // This query matches the baz script.
1110        let query = TestQuery {
1111            binary_query: target_binary_query.to_query(),
1112            test_name: "script2",
1113        };
1114        let scripts = SetupScripts::new_with_queries(&profile, std::iter::once(query));
1115        assert_eq!(scripts.len(), 1, "one script should be enabled");
1116        assert_eq!(
1117            scripts.enabled_scripts.get_index(0).unwrap().0.as_str(),
1118            "baz",
1119            "first script should be baz"
1120        );
1121
1122        // This query matches the baz, foo and tool scripts (but note the order).
1123        let query = TestQuery {
1124            binary_query: target_binary_query.to_query(),
1125            test_name: "script3",
1126        };
1127        let scripts = SetupScripts::new_with_queries(&profile, std::iter::once(query));
1128        assert_eq!(scripts.len(), 3, "three scripts should be enabled");
1129        assert_eq!(
1130            scripts.enabled_scripts.get_index(0).unwrap().0.as_str(),
1131            "@tool:my-tool:toolscript",
1132            "first script should be toolscript"
1133        );
1134        assert_eq!(
1135            scripts.enabled_scripts.get_index(1).unwrap().0.as_str(),
1136            "foo",
1137            "second script should be foo"
1138        );
1139        assert_eq!(
1140            scripts.enabled_scripts.get_index(2).unwrap().0.as_str(),
1141            "baz",
1142            "third script should be baz"
1143        );
1144    }
1145
1146    #[test_case(
1147        indoc! {r#"
1148            [scripts.setup.foo]
1149            command = ""
1150        "#},
1151        "invalid value: string \"\", expected a Unix shell command, a list of arguments, \
1152         or a table with command-line and relative-to"
1153
1154        ; "empty command"
1155    )]
1156    #[test_case(
1157        indoc! {r#"
1158            [scripts.setup.foo]
1159            command = []
1160        "#},
1161        "invalid length 0, expected a Unix shell command, a list of arguments, \
1162         or a table with command-line and relative-to"
1163
1164        ; "empty command list"
1165    )]
1166    #[test_case(
1167        indoc! {r#"
1168            [scripts.setup.foo]
1169        "#},
1170        "scripts.setup.foo: missing field `command`"
1171
1172        ; "missing command"
1173    )]
1174    #[test_case(
1175        indoc! {r#"
1176            [scripts.setup.foo]
1177            command = { command-line = "" }
1178        "#},
1179        "invalid value: string \"\", expected a non-empty command string"
1180
1181        ; "empty command-line in table"
1182    )]
1183    #[test_case(
1184        indoc! {r#"
1185            [scripts.setup.foo]
1186            command = { command-line = [] }
1187        "#},
1188        "invalid length 0, expected a string or array of strings"
1189
1190        ; "empty command-line array in table"
1191    )]
1192    #[test_case(
1193        indoc! {r#"
1194            [scripts.setup.foo]
1195            command = { relative-to = "target" }
1196        "#},
1197        "missing field `command-line`"
1198
1199        ; "missing command-line in table"
1200    )]
1201    #[test_case(
1202        indoc! {r#"
1203            [scripts.setup.foo]
1204            command = { command-line = "my-command", relative-to = "invalid" }
1205        "#},
1206        r#"unknown variant `invalid`, expected `none` or `target`"#
1207
1208        ; "invalid relative-to value"
1209    )]
1210    #[test_case(
1211        indoc! {r#"
1212            [scripts.setup.foo]
1213            command = { command-line = "my-command", unknown-field = "value" }
1214        "#},
1215        r#"unknown field `unknown-field`, expected `command-line` or `relative-to`"#
1216
1217        ; "unknown field in command table"
1218    )]
1219    #[test_case(
1220        indoc! {r#"
1221            [scripts.setup.foo]
1222            command = "my-command"
1223            slow-timeout = 34
1224        "#},
1225        r#"invalid type: integer `34`, expected a table ({ period = "60s", terminate-after = 2 }) or a string ("60s")"#
1226
1227        ; "slow timeout is not a duration"
1228    )]
1229    #[test_case(
1230        indoc! {r#"
1231            [scripts.setup.'@tool:foo']
1232            command = "my-command"
1233        "#},
1234        r#"invalid configuration script name: tool identifier not of the form "@tool:tool-name:identifier": `@tool:foo`"#
1235
1236        ; "invalid tool script name"
1237    )]
1238    #[test_case(
1239        indoc! {r#"
1240            [scripts.setup.'#foo']
1241            command = "my-command"
1242        "#},
1243        r"invalid configuration script name: invalid identifier `#foo`"
1244
1245        ; "invalid script name"
1246    )]
1247    #[test_case(
1248        indoc! {r#"
1249            [scripts.wrapper.foo]
1250            command = "my-command"
1251            target-runner = "not-a-valid-value"
1252        "#},
1253        r#"unknown variant `not-a-valid-value`, expected one of `ignore`, `overrides-wrapper`, `within-wrapper`, `around-wrapper`"#
1254
1255        ; "invalid target-runner value"
1256    )]
1257    #[test_case(
1258        indoc! {r#"
1259            [scripts.wrapper.foo]
1260            command = "my-command"
1261            target-runner = ["foo"]
1262        "#},
1263        r#"invalid type: sequence, expected a string"#
1264
1265        ; "target-runner is not a string"
1266    )]
1267    fn parse_scripts_invalid_deserialize(config_contents: &str, message: &str) {
1268        let workspace_dir = tempdir().unwrap();
1269
1270        let graph = temp_workspace(&workspace_dir, config_contents);
1271        let pcx = ParseContext::new(&graph);
1272
1273        let nextest_config_error = NextestConfig::from_sources(
1274            graph.workspace().root(),
1275            &pcx,
1276            None,
1277            &[][..],
1278            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1279        )
1280        .expect_err("config is invalid");
1281        let actual_message = DisplayErrorChain::new(nextest_config_error).to_string();
1282
1283        assert!(
1284            actual_message.contains(message),
1285            "nextest config error `{actual_message}` contains message `{message}`"
1286        );
1287    }
1288
1289    #[test_case(
1290        indoc! {r#"
1291            [scripts.setup.foo]
1292            command = "my-command"
1293
1294            [[profile.default.scripts]]
1295            setup = ["foo"]
1296        "#},
1297        "default",
1298        &[MietteJsonReport {
1299            message: "at least one of `platform` and `filter` must be specified".to_owned(),
1300            labels: vec![],
1301        }]
1302
1303        ; "neither platform nor filter specified"
1304    )]
1305    #[test_case(
1306        indoc! {r#"
1307            [scripts.setup.foo]
1308            command = "my-command"
1309
1310            [[profile.default.scripts]]
1311            platform = {}
1312            setup = ["foo"]
1313        "#},
1314        "default",
1315        &[MietteJsonReport {
1316            message: "at least one of `platform` and `filter` must be specified".to_owned(),
1317            labels: vec![],
1318        }]
1319
1320        ; "empty platform map"
1321    )]
1322    #[test_case(
1323        indoc! {r#"
1324            [scripts.setup.foo]
1325            command = "my-command"
1326
1327            [[profile.default.scripts]]
1328            platform = { host = 'cfg(target_os = "linux' }
1329            setup = ["foo"]
1330        "#},
1331        "default",
1332        &[MietteJsonReport {
1333            message: "error parsing cfg() expression".to_owned(),
1334            labels: vec![
1335                MietteJsonLabel { label: "expected one of `=`, `,`, `)` here".to_owned(), span: MietteJsonSpan { offset: 3, length: 1 } }
1336            ]
1337        }]
1338
1339        ; "invalid platform expression"
1340    )]
1341    #[test_case(
1342        indoc! {r#"
1343            [scripts.setup.foo]
1344            command = "my-command"
1345
1346            [[profile.ci.overrides]]
1347            filter = 'test(/foo)'
1348            setup = ["foo"]
1349        "#},
1350        "ci",
1351        &[MietteJsonReport {
1352            message: "expected close regex".to_owned(),
1353            labels: vec![
1354                MietteJsonLabel { label: "missing `/`".to_owned(), span: MietteJsonSpan { offset: 9, length: 0 } }
1355            ]
1356        }]
1357
1358        ; "invalid filterset"
1359    )]
1360    fn parse_scripts_invalid_compile(
1361        config_contents: &str,
1362        faulty_profile: &str,
1363        expected_reports: &[MietteJsonReport],
1364    ) {
1365        let workspace_dir = tempdir().unwrap();
1366
1367        let graph = temp_workspace(&workspace_dir, config_contents);
1368
1369        let pcx = ParseContext::new(&graph);
1370
1371        let error = NextestConfig::from_sources(
1372            graph.workspace().root(),
1373            &pcx,
1374            None,
1375            &[][..],
1376            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1377        )
1378        .expect_err("config is invalid");
1379        match error.kind() {
1380            ConfigParseErrorKind::CompileErrors(compile_errors) => {
1381                assert_eq!(
1382                    compile_errors.len(),
1383                    1,
1384                    "exactly one override error must be produced"
1385                );
1386                let error = compile_errors.first().unwrap();
1387                assert_eq!(
1388                    error.profile_name, faulty_profile,
1389                    "compile error profile matches"
1390                );
1391                let handler = miette::JSONReportHandler::new();
1392                let reports = error
1393                    .kind
1394                    .reports()
1395                    .map(|report| {
1396                        let mut out = String::new();
1397                        handler.render_report(&mut out, report.as_ref()).unwrap();
1398
1399                        let json_report: MietteJsonReport = serde_json::from_str(&out)
1400                            .unwrap_or_else(|err| {
1401                                panic!(
1402                                    "failed to deserialize JSON message produced by miette: {err}"
1403                                )
1404                            });
1405                        json_report
1406                    })
1407                    .collect::<Vec<_>>();
1408                assert_eq!(&reports, expected_reports, "reports match");
1409            }
1410            other => {
1411                panic!(
1412                    "for config error {other:?}, expected ConfigParseErrorKind::CompiledDataParseError"
1413                );
1414            }
1415        }
1416    }
1417
1418    #[test_case(
1419        indoc! {r#"
1420            [scripts.setup.'@tool:foo:bar']
1421            command = "my-command"
1422
1423            [[profile.ci.overrides]]
1424            setup = ["@tool:foo:bar"]
1425        "#},
1426        &["@tool:foo:bar"]
1427
1428        ; "tool config in main program")]
1429    fn parse_scripts_invalid_defined(config_contents: &str, expected_invalid_scripts: &[&str]) {
1430        let workspace_dir = tempdir().unwrap();
1431
1432        let graph = temp_workspace(&workspace_dir, config_contents);
1433
1434        let pcx = ParseContext::new(&graph);
1435
1436        let error = NextestConfig::from_sources(
1437            graph.workspace().root(),
1438            &pcx,
1439            None,
1440            &[][..],
1441            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1442        )
1443        .expect_err("config is invalid");
1444        match error.kind() {
1445            ConfigParseErrorKind::InvalidConfigScriptsDefined(scripts) => {
1446                assert_eq!(
1447                    scripts.len(),
1448                    expected_invalid_scripts.len(),
1449                    "correct number of scripts defined"
1450                );
1451                for (script, expected_script) in scripts.iter().zip(expected_invalid_scripts) {
1452                    assert_eq!(script.as_str(), *expected_script, "script name matches");
1453                }
1454            }
1455            other => {
1456                panic!(
1457                    "for config error {other:?}, expected ConfigParseErrorKind::InvalidConfigScriptsDefined"
1458                );
1459            }
1460        }
1461    }
1462
1463    #[test_case(
1464        indoc! {r#"
1465            [scripts.setup.'blarg']
1466            command = "my-command"
1467
1468            [[profile.ci.overrides]]
1469            setup = ["blarg"]
1470        "#},
1471        &["blarg"]
1472
1473        ; "non-tool config in tool")]
1474    fn parse_scripts_invalid_defined_by_tool(
1475        tool_config_contents: &str,
1476        expected_invalid_scripts: &[&str],
1477    ) {
1478        let workspace_dir = tempdir().unwrap();
1479        let graph = temp_workspace(&workspace_dir, "");
1480
1481        let tool_path = workspace_dir.child(".config/my-tool.toml");
1482        tool_path.write_str(tool_config_contents).unwrap();
1483        let tool_config_files = [ToolConfigFile {
1484            tool: "my-tool".to_owned(),
1485            config_file: tool_path.to_path_buf(),
1486        }];
1487
1488        let pcx = ParseContext::new(&graph);
1489
1490        let error = NextestConfig::from_sources(
1491            graph.workspace().root(),
1492            &pcx,
1493            None,
1494            &tool_config_files,
1495            &btreeset! { ConfigExperimental::SetupScripts },
1496        )
1497        .expect_err("config is invalid");
1498        match error.kind() {
1499            ConfigParseErrorKind::InvalidConfigScriptsDefinedByTool(scripts) => {
1500                assert_eq!(
1501                    scripts.len(),
1502                    expected_invalid_scripts.len(),
1503                    "exactly one script must be defined"
1504                );
1505                for (script, expected_script) in scripts.iter().zip(expected_invalid_scripts) {
1506                    assert_eq!(script.as_str(), *expected_script, "script name matches");
1507                }
1508            }
1509            other => {
1510                panic!(
1511                    "for config error {other:?}, expected ConfigParseErrorKind::InvalidConfigScriptsDefinedByTool"
1512                );
1513            }
1514        }
1515    }
1516
1517    #[test_case(
1518        indoc! {r#"
1519            [scripts.setup.foo]
1520            command = 'echo foo'
1521
1522            [[profile.default.scripts]]
1523            platform = 'cfg(unix)'
1524            setup = ['bar']
1525
1526            [[profile.ci.scripts]]
1527            platform = 'cfg(unix)'
1528            setup = ['baz']
1529        "#},
1530        vec![
1531            ProfileUnknownScriptError {
1532                profile_name: "default".to_owned(),
1533                name: ScriptId::new("bar".into()).unwrap(),
1534            },
1535            ProfileUnknownScriptError {
1536                profile_name: "ci".to_owned(),
1537                name: ScriptId::new("baz".into()).unwrap(),
1538            },
1539        ],
1540        &["foo"]
1541
1542        ; "unknown scripts"
1543    )]
1544    fn parse_scripts_invalid_unknown(
1545        config_contents: &str,
1546        expected_errors: Vec<ProfileUnknownScriptError>,
1547        expected_known_scripts: &[&str],
1548    ) {
1549        let workspace_dir = tempdir().unwrap();
1550
1551        let graph = temp_workspace(&workspace_dir, config_contents);
1552
1553        let pcx = ParseContext::new(&graph);
1554
1555        let error = NextestConfig::from_sources(
1556            graph.workspace().root(),
1557            &pcx,
1558            None,
1559            &[][..],
1560            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1561        )
1562        .expect_err("config is invalid");
1563        match error.kind() {
1564            ConfigParseErrorKind::ProfileScriptErrors {
1565                errors,
1566                known_scripts,
1567            } => {
1568                let ProfileScriptErrors {
1569                    unknown_scripts,
1570                    wrong_script_types,
1571                    list_scripts_using_run_filters,
1572                } = &**errors;
1573                assert_eq!(wrong_script_types.len(), 0, "no wrong script types");
1574                assert_eq!(
1575                    list_scripts_using_run_filters.len(),
1576                    0,
1577                    "no scripts using run filters in list phase"
1578                );
1579                assert_eq!(
1580                    unknown_scripts.len(),
1581                    expected_errors.len(),
1582                    "correct number of errors"
1583                );
1584                for (error, expected_error) in unknown_scripts.iter().zip(expected_errors) {
1585                    assert_eq!(error, &expected_error, "error matches");
1586                }
1587                assert_eq!(
1588                    known_scripts.len(),
1589                    expected_known_scripts.len(),
1590                    "correct number of known scripts"
1591                );
1592                for (script, expected_script) in known_scripts.iter().zip(expected_known_scripts) {
1593                    assert_eq!(
1594                        script.as_str(),
1595                        *expected_script,
1596                        "known script name matches"
1597                    );
1598                }
1599            }
1600            other => {
1601                panic!(
1602                    "for config error {other:?}, expected ConfigParseErrorKind::ProfileScriptErrors"
1603                );
1604            }
1605        }
1606    }
1607
1608    #[test_case(
1609        indoc! {r#"
1610            [scripts.setup.setup-script]
1611            command = 'echo setup'
1612
1613            [scripts.wrapper.wrapper-script]
1614            command = 'echo wrapper'
1615
1616            [[profile.default.scripts]]
1617            platform = 'cfg(unix)'
1618            setup = ['wrapper-script']
1619            list-wrapper = 'setup-script'
1620
1621            [[profile.ci.scripts]]
1622            platform = 'cfg(unix)'
1623            setup = 'wrapper-script'
1624            run-wrapper = 'setup-script'
1625        "#},
1626        vec![
1627            ProfileWrongConfigScriptTypeError {
1628                profile_name: "default".to_owned(),
1629                name: ScriptId::new("wrapper-script".into()).unwrap(),
1630                attempted: ProfileScriptType::Setup,
1631                actual: ScriptType::Wrapper,
1632            },
1633            ProfileWrongConfigScriptTypeError {
1634                profile_name: "default".to_owned(),
1635                name: ScriptId::new("setup-script".into()).unwrap(),
1636                attempted: ProfileScriptType::ListWrapper,
1637                actual: ScriptType::Setup,
1638            },
1639            ProfileWrongConfigScriptTypeError {
1640                profile_name: "ci".to_owned(),
1641                name: ScriptId::new("wrapper-script".into()).unwrap(),
1642                attempted: ProfileScriptType::Setup,
1643                actual: ScriptType::Wrapper,
1644            },
1645            ProfileWrongConfigScriptTypeError {
1646                profile_name: "ci".to_owned(),
1647                name: ScriptId::new("setup-script".into()).unwrap(),
1648                attempted: ProfileScriptType::RunWrapper,
1649                actual: ScriptType::Setup,
1650            },
1651        ],
1652        &["setup-script", "wrapper-script"]
1653
1654        ; "wrong script types"
1655    )]
1656    fn parse_scripts_invalid_wrong_type(
1657        config_contents: &str,
1658        expected_errors: Vec<ProfileWrongConfigScriptTypeError>,
1659        expected_known_scripts: &[&str],
1660    ) {
1661        let workspace_dir = tempdir().unwrap();
1662
1663        let graph = temp_workspace(&workspace_dir, config_contents);
1664
1665        let pcx = ParseContext::new(&graph);
1666
1667        let error = NextestConfig::from_sources(
1668            graph.workspace().root(),
1669            &pcx,
1670            None,
1671            &[][..],
1672            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1673        )
1674        .expect_err("config is invalid");
1675        match error.kind() {
1676            ConfigParseErrorKind::ProfileScriptErrors {
1677                errors,
1678                known_scripts,
1679            } => {
1680                let ProfileScriptErrors {
1681                    unknown_scripts,
1682                    wrong_script_types,
1683                    list_scripts_using_run_filters,
1684                } = &**errors;
1685                assert_eq!(unknown_scripts.len(), 0, "no unknown scripts");
1686                assert_eq!(
1687                    list_scripts_using_run_filters.len(),
1688                    0,
1689                    "no scripts using run filters in list phase"
1690                );
1691                assert_eq!(
1692                    wrong_script_types.len(),
1693                    expected_errors.len(),
1694                    "correct number of errors"
1695                );
1696                for (error, expected_error) in wrong_script_types.iter().zip(expected_errors) {
1697                    assert_eq!(error, &expected_error, "error matches");
1698                }
1699                assert_eq!(
1700                    known_scripts.len(),
1701                    expected_known_scripts.len(),
1702                    "correct number of known scripts"
1703                );
1704                for (script, expected_script) in known_scripts.iter().zip(expected_known_scripts) {
1705                    assert_eq!(
1706                        script.as_str(),
1707                        *expected_script,
1708                        "known script name matches"
1709                    );
1710                }
1711            }
1712            other => {
1713                panic!(
1714                    "for config error {other:?}, expected ConfigParseErrorKind::ProfileScriptErrors"
1715                );
1716            }
1717        }
1718    }
1719
1720    #[test_case(
1721        indoc! {r#"
1722            [scripts.wrapper.list-script]
1723            command = 'echo list'
1724
1725            [[profile.default.scripts]]
1726            filter = 'test(hello)'
1727            list-wrapper = 'list-script'
1728
1729            [[profile.ci.scripts]]
1730            filter = 'test(world)'
1731            list-wrapper = 'list-script'
1732        "#},
1733        vec![
1734            ProfileListScriptUsesRunFiltersError {
1735                profile_name: "default".to_owned(),
1736                name: ScriptId::new("list-script".into()).unwrap(),
1737                script_type: ProfileScriptType::ListWrapper,
1738                filters: vec!["test(hello)".to_owned()].into_iter().collect(),
1739            },
1740            ProfileListScriptUsesRunFiltersError {
1741                profile_name: "ci".to_owned(),
1742                name: ScriptId::new("list-script".into()).unwrap(),
1743                script_type: ProfileScriptType::ListWrapper,
1744                filters: vec!["test(world)".to_owned()].into_iter().collect(),
1745            },
1746        ],
1747        &["list-script"]
1748
1749        ; "list scripts using run filters"
1750    )]
1751    fn parse_scripts_invalid_list_using_run_filters(
1752        config_contents: &str,
1753        expected_errors: Vec<ProfileListScriptUsesRunFiltersError>,
1754        expected_known_scripts: &[&str],
1755    ) {
1756        let workspace_dir = tempdir().unwrap();
1757
1758        let graph = temp_workspace(&workspace_dir, config_contents);
1759
1760        let pcx = ParseContext::new(&graph);
1761
1762        let error = NextestConfig::from_sources(
1763            graph.workspace().root(),
1764            &pcx,
1765            None,
1766            &[][..],
1767            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1768        )
1769        .expect_err("config is invalid");
1770        match error.kind() {
1771            ConfigParseErrorKind::ProfileScriptErrors {
1772                errors,
1773                known_scripts,
1774            } => {
1775                let ProfileScriptErrors {
1776                    unknown_scripts,
1777                    wrong_script_types,
1778                    list_scripts_using_run_filters,
1779                } = &**errors;
1780                assert_eq!(unknown_scripts.len(), 0, "no unknown scripts");
1781                assert_eq!(wrong_script_types.len(), 0, "no wrong script types");
1782                assert_eq!(
1783                    list_scripts_using_run_filters.len(),
1784                    expected_errors.len(),
1785                    "correct number of errors"
1786                );
1787                for (error, expected_error) in
1788                    list_scripts_using_run_filters.iter().zip(expected_errors)
1789                {
1790                    assert_eq!(error, &expected_error, "error matches");
1791                }
1792                assert_eq!(
1793                    known_scripts.len(),
1794                    expected_known_scripts.len(),
1795                    "correct number of known scripts"
1796                );
1797                for (script, expected_script) in known_scripts.iter().zip(expected_known_scripts) {
1798                    assert_eq!(
1799                        script.as_str(),
1800                        *expected_script,
1801                        "known script name matches"
1802                    );
1803                }
1804            }
1805            other => {
1806                panic!(
1807                    "for config error {other:?}, expected ConfigParseErrorKind::ProfileScriptErrors"
1808                );
1809            }
1810        }
1811    }
1812
1813    #[test]
1814    fn test_parse_scripts_empty_sections() {
1815        let config_contents = indoc! {r#"
1816            [scripts.setup.foo]
1817            command = 'echo foo'
1818
1819            [[profile.default.scripts]]
1820            platform = 'cfg(unix)'
1821
1822            [[profile.ci.scripts]]
1823            platform = 'cfg(unix)'
1824        "#};
1825
1826        let workspace_dir = tempdir().unwrap();
1827
1828        let graph = temp_workspace(&workspace_dir, config_contents);
1829
1830        let pcx = ParseContext::new(&graph);
1831
1832        // The config should still be valid, just with warnings
1833        let result = NextestConfig::from_sources(
1834            graph.workspace().root(),
1835            &pcx,
1836            None,
1837            &[][..],
1838            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1839        );
1840
1841        match result {
1842            Ok(_config) => {
1843                // Config should be valid, warnings are just printed to stderr
1844                // The warnings we added should have been printed during config parsing
1845            }
1846            Err(e) => {
1847                panic!("Config should be valid but got error: {e:?}");
1848            }
1849        }
1850    }
1851}