Skip to main content

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