nextest_runner/config/scripts/
imp.rs

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