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