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/// The 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    /// The setup scripts defined in nextest's configuration.
51    #[serde(default)]
52    pub setup: IndexMap<ScriptId, SetupScriptConfig>,
53    /// The wrapper scripts defined in nextest's configuration.
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    /// The host and/or target platforms to match against.
620    #[serde(default)]
621    pub(in crate::config) platform: PlatformStrings,
622
623    /// The filterset to match against.
624    #[serde(default)]
625    filter: Option<String>,
626
627    /// The setup script or scripts to run.
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    /// The wrapper script to run at list time.
633    #[serde(default)]
634    list_wrapper: Option<ScriptId>,
635
636    /// The wrapper script to run at run time.
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. The first element is the program and the second element is a list
663    /// of arguments.
664    pub command: ScriptCommand,
665
666    /// An optional slow timeout for this command.
667    #[serde(
668        default,
669        deserialize_with = "crate::config::elements::deserialize_slow_timeout"
670    )]
671    pub slow_timeout: Option<SlowTimeout>,
672
673    /// An optional leak timeout for this command.
674    #[serde(
675        default,
676        deserialize_with = "crate::config::elements::deserialize_leak_timeout"
677    )]
678    pub leak_timeout: Option<LeakTimeout>,
679
680    /// Whether to capture standard output for this command.
681    #[serde(default)]
682    pub capture_stdout: bool,
683
684    /// Whether to capture standard error for this command.
685    #[serde(default)]
686    pub capture_stderr: bool,
687
688    /// JUnit configuration for this script.
689    #[serde(default)]
690    pub junit: SetupScriptJunitConfig,
691}
692
693impl SetupScriptConfig {
694    /// Returns true if at least some output isn't being captured.
695    #[inline]
696    pub fn no_capture(&self) -> bool {
697        !(self.capture_stdout && self.capture_stderr)
698    }
699}
700
701/// A JUnit override configuration.
702#[derive(Copy, Clone, Debug, Deserialize)]
703#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
704#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
705#[serde(rename_all = "kebab-case")]
706pub struct SetupScriptJunitConfig {
707    /// Whether to store successful output.
708    ///
709    /// Defaults to true.
710    #[serde(default = "default_true")]
711    pub store_success_output: bool,
712
713    /// Whether to store failing output.
714    ///
715    /// Defaults to true.
716    #[serde(default = "default_true")]
717    pub store_failure_output: bool,
718}
719
720impl Default for SetupScriptJunitConfig {
721    fn default() -> Self {
722        Self {
723            store_success_output: true,
724            store_failure_output: true,
725        }
726    }
727}
728
729/// Deserialized form of wrapper script configuration before compilation.
730///
731/// This is defined as a top-level element.
732#[derive(Clone, Debug, Deserialize)]
733#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
734#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
735#[serde(rename_all = "kebab-case")]
736pub struct WrapperScriptConfig {
737    /// The command to run.
738    pub command: ScriptCommand,
739
740    /// How this script interacts with a configured target runner, if any.
741    /// Defaults to ignoring the target runner.
742    #[serde(default)]
743    pub target_runner: WrapperScriptTargetRunner,
744}
745
746/// Interaction of wrapper script with a configured target runner.
747#[derive(Clone, Debug, Default)]
748#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
749#[cfg_attr(feature = "config-schema", schemars(rename_all = "kebab-case"))]
750pub enum WrapperScriptTargetRunner {
751    /// The target runner is ignored. This is the default.
752    #[default]
753    Ignore,
754
755    /// The target runner overrides the wrapper.
756    OverridesWrapper,
757
758    /// The target runner runs within the wrapper script. The command line used
759    /// is `<wrapper> <target-runner> <test-binary> <args>`.
760    WithinWrapper,
761
762    /// The target runner runs around the wrapper script. The command line used
763    /// is `<target-runner> <wrapper> <test-binary> <args>`.
764    AroundWrapper,
765}
766
767impl<'de> Deserialize<'de> for WrapperScriptTargetRunner {
768    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
769    where
770        D: serde::Deserializer<'de>,
771    {
772        let s = String::deserialize(deserializer)?;
773        match s.as_str() {
774            "ignore" => Ok(WrapperScriptTargetRunner::Ignore),
775            "overrides-wrapper" => Ok(WrapperScriptTargetRunner::OverridesWrapper),
776            "within-wrapper" => Ok(WrapperScriptTargetRunner::WithinWrapper),
777            "around-wrapper" => Ok(WrapperScriptTargetRunner::AroundWrapper),
778            _ => Err(serde::de::Error::unknown_variant(
779                &s,
780                &[
781                    "ignore",
782                    "overrides-wrapper",
783                    "within-wrapper",
784                    "around-wrapper",
785                ],
786            )),
787        }
788    }
789}
790
791fn default_true() -> bool {
792    true
793}
794
795fn deserialize_script_ids<'de, D>(deserializer: D) -> Result<Vec<ScriptId>, D::Error>
796where
797    D: serde::Deserializer<'de>,
798{
799    struct ScriptIdVisitor;
800
801    impl<'de> serde::de::Visitor<'de> for ScriptIdVisitor {
802        type Value = Vec<ScriptId>;
803
804        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
805            formatter.write_str("a script ID (string) or a list of script IDs")
806        }
807
808        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
809        where
810            E: serde::de::Error,
811        {
812            Ok(vec![ScriptId::new(value.into()).map_err(E::custom)?])
813        }
814
815        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
816        where
817            A: serde::de::SeqAccess<'de>,
818        {
819            let mut ids = Vec::new();
820            while let Some(value) = seq.next_element::<String>()? {
821                ids.push(ScriptId::new(value.into()).map_err(A::Error::custom)?);
822            }
823            Ok(ids)
824        }
825    }
826
827    deserializer.deserialize_any(ScriptIdVisitor)
828}
829
830/// The script command to run.
831#[derive(Clone, Debug)]
832pub struct ScriptCommand {
833    /// The program to run.
834    pub program: String,
835
836    /// The arguments to pass to the program.
837    pub args: Vec<String>,
838
839    /// A map of environment variables to pass to the program.
840    pub env: ScriptCommandEnvMap,
841
842    /// Which directory to interpret the program as relative to.
843    ///
844    /// This controls just how `program` is interpreted, in case it is a
845    /// relative path.
846    pub relative_to: ScriptCommandRelativeTo,
847}
848
849impl ScriptCommand {
850    /// Returns the program to run, resolved with respect to the target directory.
851    pub fn program(&self, workspace_root: &Utf8Path, target_dir: &Utf8Path) -> String {
852        match self.relative_to {
853            ScriptCommandRelativeTo::None => self.program.clone(),
854            ScriptCommandRelativeTo::WorkspaceRoot => {
855                // If the path is relative, convert it to the main separator.
856                let path = Utf8Path::new(&self.program);
857                if path.is_relative() {
858                    workspace_root
859                        .join(convert_rel_path_to_main_sep(path))
860                        .to_string()
861                } else {
862                    path.to_string()
863                }
864            }
865            ScriptCommandRelativeTo::Target => {
866                // If the path is relative, convert it to the main separator.
867                let path = Utf8Path::new(&self.program);
868                if path.is_relative() {
869                    target_dir
870                        .join(convert_rel_path_to_main_sep(path))
871                        .to_string()
872                } else {
873                    path.to_string()
874                }
875            }
876        }
877    }
878}
879
880impl<'de> Deserialize<'de> for ScriptCommand {
881    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
882    where
883        D: serde::Deserializer<'de>,
884    {
885        struct CommandVisitor;
886
887        impl<'de> serde::de::Visitor<'de> for CommandVisitor {
888            type Value = ScriptCommand;
889
890            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
891                formatter.write_str("a Unix shell command, a list of arguments, or a table with command-line, env, and relative-to")
892            }
893
894            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
895            where
896                E: serde::de::Error,
897            {
898                let mut args = shell_words::split(value).map_err(E::custom)?;
899                if args.is_empty() {
900                    return Err(E::invalid_value(serde::de::Unexpected::Str(value), &self));
901                }
902                let program = args.remove(0);
903                Ok(ScriptCommand {
904                    program,
905                    args,
906                    env: ScriptCommandEnvMap::default(),
907                    relative_to: ScriptCommandRelativeTo::None,
908                })
909            }
910
911            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
912            where
913                A: serde::de::SeqAccess<'de>,
914            {
915                let Some(program) = seq.next_element::<String>()? else {
916                    return Err(A::Error::invalid_length(0, &self));
917                };
918                let mut args = Vec::new();
919                while let Some(value) = seq.next_element::<String>()? {
920                    args.push(value);
921                }
922                Ok(ScriptCommand {
923                    program,
924                    args,
925                    env: ScriptCommandEnvMap::default(),
926                    relative_to: ScriptCommandRelativeTo::None,
927                })
928            }
929
930            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
931            where
932                A: serde::de::MapAccess<'de>,
933            {
934                let mut command_line = None;
935                let mut relative_to = None;
936                let mut env = None;
937
938                while let Some(key) = map.next_key::<String>()? {
939                    match key.as_str() {
940                        "command-line" => {
941                            if command_line.is_some() {
942                                return Err(A::Error::duplicate_field("command-line"));
943                            }
944                            command_line = Some(map.next_value_seed(CommandInnerSeed)?);
945                        }
946                        "relative-to" => {
947                            if relative_to.is_some() {
948                                return Err(A::Error::duplicate_field("relative-to"));
949                            }
950                            relative_to = Some(map.next_value::<ScriptCommandRelativeTo>()?);
951                        }
952                        "env" => {
953                            if env.is_some() {
954                                return Err(A::Error::duplicate_field("env"));
955                            }
956                            env = Some(map.next_value::<ScriptCommandEnvMap>()?);
957                        }
958                        _ => {
959                            return Err(A::Error::unknown_field(
960                                &key,
961                                &["command-line", "env", "relative-to"],
962                            ));
963                        }
964                    }
965                }
966
967                let (program, arguments) =
968                    command_line.ok_or_else(|| A::Error::missing_field("command-line"))?;
969                let env = env.unwrap_or_default();
970                let relative_to = relative_to.unwrap_or(ScriptCommandRelativeTo::None);
971
972                Ok(ScriptCommand {
973                    program,
974                    args: arguments,
975                    env,
976                    relative_to,
977                })
978            }
979        }
980
981        deserializer.deserialize_any(CommandVisitor)
982    }
983}
984
985#[cfg(feature = "config-schema")]
986impl schemars::JsonSchema for ScriptCommand {
987    fn schema_name() -> std::borrow::Cow<'static, str> {
988        "ScriptCommand".into()
989    }
990
991    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
992        fn non_empty_string_array_schema(
993            generator: &mut schemars::SchemaGenerator,
994        ) -> schemars::Schema {
995            schemars::json_schema!({
996                "type": "array",
997                "items": generator.subschema_for::<String>(),
998                "minItems": 1,
999            })
1000        }
1001
1002        schemars::json_schema!({
1003            "title": "ScriptCommand",
1004            "oneOf": [
1005                generator.subschema_for::<String>(),
1006                non_empty_string_array_schema(generator),
1007                {
1008                    "type": "object",
1009                    "properties": {
1010                        "command-line": {
1011                            "oneOf": [
1012                                generator.subschema_for::<String>(),
1013                                non_empty_string_array_schema(generator),
1014                            ]
1015                        },
1016                        "env": generator.subschema_for::<std::collections::BTreeMap<String, String>>(),
1017                        "relative-to": generator.subschema_for::<ScriptCommandRelativeTo>(),
1018                    },
1019                    "required": ["command-line"],
1020                    "additionalProperties": false,
1021                }
1022            ]
1023        })
1024    }
1025}
1026
1027struct CommandInnerSeed;
1028
1029impl<'de> serde::de::DeserializeSeed<'de> for CommandInnerSeed {
1030    type Value = (String, Vec<String>);
1031
1032    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1033    where
1034        D: serde::Deserializer<'de>,
1035    {
1036        struct CommandInnerVisitor;
1037
1038        impl<'de> serde::de::Visitor<'de> for CommandInnerVisitor {
1039            type Value = (String, Vec<String>);
1040
1041            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
1042                formatter.write_str("a string or array of strings")
1043            }
1044
1045            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
1046            where
1047                E: serde::de::Error,
1048            {
1049                let mut args = shell_words::split(value).map_err(E::custom)?;
1050                if args.is_empty() {
1051                    return Err(E::invalid_value(
1052                        serde::de::Unexpected::Str(value),
1053                        &"a non-empty command string",
1054                    ));
1055                }
1056                let program = args.remove(0);
1057                Ok((program, args))
1058            }
1059
1060            fn visit_seq<S>(self, mut seq: S) -> Result<Self::Value, S::Error>
1061            where
1062                S: serde::de::SeqAccess<'de>,
1063            {
1064                let mut args = Vec::new();
1065                while let Some(value) = seq.next_element::<String>()? {
1066                    args.push(value);
1067                }
1068                if args.is_empty() {
1069                    return Err(S::Error::invalid_length(0, &self));
1070                }
1071                let program = args.remove(0);
1072                Ok((program, args))
1073            }
1074        }
1075
1076        deserializer.deserialize_any(CommandInnerVisitor)
1077    }
1078}
1079
1080/// The directory to interpret a [`ScriptCommand`] as relative to, in case it is
1081/// a relative path.
1082///
1083/// If specified, the program will be joined with the provided path.
1084#[derive(Clone, Copy, Debug)]
1085#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
1086#[cfg_attr(feature = "config-schema", schemars(rename_all = "kebab-case"))]
1087pub enum ScriptCommandRelativeTo {
1088    /// Do not join the program with any path.
1089    None,
1090
1091    /// Join the program with the workspace root.
1092    WorkspaceRoot,
1093
1094    /// Join the program with the target directory.
1095    Target,
1096    // TODO: TargetProfile, similar to ArchiveRelativeTo
1097}
1098
1099impl<'de> Deserialize<'de> for ScriptCommandRelativeTo {
1100    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1101    where
1102        D: serde::Deserializer<'de>,
1103    {
1104        let s = String::deserialize(deserializer)?;
1105        match s.as_str() {
1106            "none" => Ok(ScriptCommandRelativeTo::None),
1107            "workspace-root" => Ok(ScriptCommandRelativeTo::WorkspaceRoot),
1108            "target" => Ok(ScriptCommandRelativeTo::Target),
1109            _ => Err(serde::de::Error::unknown_variant(&s, &["none", "target"])),
1110        }
1111    }
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116    use super::*;
1117    use crate::{
1118        config::{
1119            core::{ConfigExperimental, NextestConfig, ToolConfigFile, ToolName},
1120            utils::test_helpers::*,
1121        },
1122        errors::{
1123            ConfigParseErrorKind, DisplayErrorChain, ProfileListScriptUsesRunFiltersError,
1124            ProfileScriptErrors, ProfileUnknownScriptError, ProfileWrongConfigScriptTypeError,
1125        },
1126    };
1127    use camino_tempfile::tempdir;
1128    use camino_tempfile_ext::prelude::*;
1129    use indoc::indoc;
1130    use maplit::btreeset;
1131    use nextest_metadata::TestCaseName;
1132    use test_case::test_case;
1133
1134    fn tool_name(s: &str) -> ToolName {
1135        ToolName::new(s.into()).unwrap()
1136    }
1137
1138    #[test]
1139    fn test_scripts_basic() {
1140        let config_contents = indoc! {r#"
1141            [[profile.default.scripts]]
1142            platform = { host = "x86_64-unknown-linux-gnu" }
1143            filter = "test(script1)"
1144            setup = ["foo", "bar"]
1145
1146            [[profile.default.scripts]]
1147            platform = { target = "aarch64-apple-darwin" }
1148            filter = "test(script2)"
1149            setup = "baz"
1150
1151            [[profile.default.scripts]]
1152            filter = "test(script3)"
1153            # No matter which order scripts are specified here, they must always be run in the
1154            # order defined below.
1155            setup = ["baz", "foo", "@tool:my-tool:toolscript"]
1156
1157            [[profile.default.scripts]]
1158            filter = "test(script4)"
1159            setup = "qux"
1160
1161            [scripts.setup.foo]
1162            command = "command foo"
1163
1164            [scripts.setup.bar]
1165            command = ["cargo", "run", "-p", "bar"]
1166            slow-timeout = { period = "60s", terminate-after = 2 }
1167
1168            [scripts.setup.baz]
1169            command = "baz"
1170            slow-timeout = "1s"
1171            leak-timeout = "1s"
1172            capture-stdout = true
1173            capture-stderr = true
1174
1175            [scripts.setup.qux]
1176            command = {
1177                command-line = "qux",
1178                env = {
1179                    MODE = "qux_mode",
1180                },
1181            }
1182        "#
1183        };
1184
1185        let tool_config_contents = indoc! {r#"
1186            [scripts.setup.'@tool:my-tool:toolscript']
1187            command = "tool-command"
1188            "#
1189        };
1190
1191        let workspace_dir = tempdir().unwrap();
1192
1193        let graph = temp_workspace(&workspace_dir, config_contents);
1194        let tool_path = workspace_dir.child(".config/my-tool.toml");
1195        tool_path.write_str(tool_config_contents).unwrap();
1196
1197        let package_id = graph.workspace().iter().next().unwrap().id();
1198
1199        let pcx = ParseContext::new(&graph);
1200
1201        let tool_config_files = [ToolConfigFile {
1202            tool: tool_name("my-tool"),
1203            config_file: tool_path.to_path_buf(),
1204        }];
1205
1206        // First, check that if the experimental feature isn't enabled, we get an error.
1207        let nextest_config_error = NextestConfig::from_sources(
1208            graph.workspace().root(),
1209            &pcx,
1210            None,
1211            &tool_config_files,
1212            &Default::default(),
1213        )
1214        .unwrap_err();
1215        match nextest_config_error.kind() {
1216            ConfigParseErrorKind::ExperimentalFeaturesNotEnabled { missing_features } => {
1217                assert_eq!(
1218                    *missing_features,
1219                    btreeset! { ConfigExperimental::SetupScripts }
1220                );
1221            }
1222            other => panic!("unexpected error kind: {other:?}"),
1223        }
1224
1225        // Now, check with the experimental feature enabled.
1226        let nextest_config_result = NextestConfig::from_sources(
1227            graph.workspace().root(),
1228            &pcx,
1229            None,
1230            &tool_config_files,
1231            &btreeset! { ConfigExperimental::SetupScripts },
1232        )
1233        .expect("config is valid");
1234        let profile = nextest_config_result
1235            .profile("default")
1236            .expect("valid profile name")
1237            .apply_build_platforms(&build_platforms());
1238
1239        // This query matches the foo and bar scripts.
1240        let host_binary_query =
1241            binary_query(&graph, package_id, "lib", "my-binary", BuildPlatform::Host);
1242        let test_name = TestCaseName::new("script1");
1243        let query = TestQuery {
1244            binary_query: host_binary_query.to_query(),
1245            test_name: &test_name,
1246        };
1247        let scripts = SetupScripts::new_with_queries(&profile, std::iter::once(query));
1248        assert_eq!(scripts.len(), 2, "two scripts should be enabled");
1249        assert_eq!(
1250            scripts.enabled_scripts.get_index(0).unwrap().0.as_str(),
1251            "foo",
1252            "first script should be foo"
1253        );
1254        assert_eq!(
1255            scripts.enabled_scripts.get_index(1).unwrap().0.as_str(),
1256            "bar",
1257            "second script should be bar"
1258        );
1259
1260        let target_binary_query = binary_query(
1261            &graph,
1262            package_id,
1263            "lib",
1264            "my-binary",
1265            BuildPlatform::Target,
1266        );
1267
1268        // This query matches the baz script.
1269        let test_name = TestCaseName::new("script2");
1270        let query = TestQuery {
1271            binary_query: target_binary_query.to_query(),
1272            test_name: &test_name,
1273        };
1274        let scripts = SetupScripts::new_with_queries(&profile, std::iter::once(query));
1275        assert_eq!(scripts.len(), 1, "one script should be enabled");
1276        assert_eq!(
1277            scripts.enabled_scripts.get_index(0).unwrap().0.as_str(),
1278            "baz",
1279            "first script should be baz"
1280        );
1281
1282        // This query matches the baz, foo and tool scripts (but note the order).
1283        let test_name = TestCaseName::new("script3");
1284        let query = TestQuery {
1285            binary_query: target_binary_query.to_query(),
1286            test_name: &test_name,
1287        };
1288        let scripts = SetupScripts::new_with_queries(&profile, std::iter::once(query));
1289        assert_eq!(scripts.len(), 3, "three scripts should be enabled");
1290        assert_eq!(
1291            scripts.enabled_scripts.get_index(0).unwrap().0.as_str(),
1292            "@tool:my-tool:toolscript",
1293            "first script should be toolscript"
1294        );
1295        assert_eq!(
1296            scripts.enabled_scripts.get_index(1).unwrap().0.as_str(),
1297            "foo",
1298            "second script should be foo"
1299        );
1300        assert_eq!(
1301            scripts.enabled_scripts.get_index(2).unwrap().0.as_str(),
1302            "baz",
1303            "third script should be baz"
1304        );
1305
1306        // This query matches the qux script.
1307        let test_name = TestCaseName::new("script4");
1308        let query = TestQuery {
1309            binary_query: target_binary_query.to_query(),
1310            test_name: &test_name,
1311        };
1312        let scripts = SetupScripts::new_with_queries(&profile, std::iter::once(query));
1313        assert_eq!(scripts.len(), 1, "one script should be enabled");
1314        assert_eq!(
1315            scripts.enabled_scripts.get_index(0).unwrap().0.as_str(),
1316            "qux",
1317            "first script should be qux"
1318        );
1319        assert_eq!(
1320            scripts
1321                .enabled_scripts
1322                .get_index(0)
1323                .unwrap()
1324                .1
1325                .config
1326                .command
1327                .env
1328                .get("MODE"),
1329            Some("qux_mode"),
1330            "first script should be passed environment variable MODE with value qux_mode",
1331        );
1332    }
1333
1334    #[test_case(
1335        indoc! {r#"
1336            [scripts.setup.foo]
1337            command = ""
1338        "#},
1339        "invalid value: string \"\", expected a Unix shell command, a list of arguments, \
1340         or a table with command-line, env, and relative-to"
1341
1342        ; "empty command"
1343    )]
1344    #[test_case(
1345        indoc! {r#"
1346            [scripts.setup.foo]
1347            command = []
1348        "#},
1349        "invalid length 0, expected a Unix shell command, a list of arguments, \
1350         or a table with command-line, env, and relative-to"
1351
1352        ; "empty command list"
1353    )]
1354    #[test_case(
1355        indoc! {r#"
1356            [scripts.setup.foo]
1357        "#},
1358        r#"scripts.setup.foo: missing configuration field "scripts.setup.foo.command""#
1359
1360        ; "missing command"
1361    )]
1362    #[test_case(
1363        indoc! {r#"
1364            [scripts.setup.foo]
1365            command = { command-line = "" }
1366        "#},
1367        "invalid value: string \"\", expected a non-empty command string"
1368
1369        ; "empty command-line in table"
1370    )]
1371    #[test_case(
1372        indoc! {r#"
1373            [scripts.setup.foo]
1374            command = { command-line = [] }
1375        "#},
1376        "invalid length 0, expected a string or array of strings"
1377
1378        ; "empty command-line array in table"
1379    )]
1380    #[test_case(
1381        indoc! {r#"
1382            [scripts.setup.foo]
1383            command = {
1384                command_line = "hi",
1385                command_line = ["hi"],
1386            }
1387        "#},
1388        r#"duplicate key"#
1389
1390        ; "command line is duplicate"
1391    )]
1392    #[test_case(
1393        indoc! {r#"
1394            [scripts.setup.foo]
1395            command = { relative-to = "target" }
1396        "#},
1397        r#"missing configuration field "scripts.setup.foo.command.command-line""#
1398
1399        ; "missing command-line in table"
1400    )]
1401    #[test_case(
1402        indoc! {r#"
1403            [scripts.setup.foo]
1404            command = { command-line = "my-command", relative-to = "invalid" }
1405        "#},
1406        r#"unknown variant `invalid`, expected `none` or `target`"#
1407
1408        ; "invalid relative-to value"
1409    )]
1410    #[test_case(
1411        indoc! {r#"
1412            [scripts.setup.foo]
1413            command = {
1414                relative-to = "none",
1415                relative-to = "target",
1416            }
1417        "#},
1418        r#"duplicate key"#
1419
1420        ; "relative to is duplicate"
1421    )]
1422    #[test_case(
1423        indoc! {r#"
1424            [scripts.setup.foo]
1425            command = { command-line = "my-command", unknown-field = "value" }
1426        "#},
1427        r#"unknown field `unknown-field`, expected one of `command-line`, `env`, `relative-to`"#
1428
1429        ; "unknown field in command table"
1430    )]
1431    #[test_case(
1432        indoc! {r#"
1433            [scripts.setup.foo]
1434            command = "my-command"
1435            slow-timeout = 34
1436        "#},
1437        r#"invalid type: integer `34`, expected a table ({ period = "60s", terminate-after = 2 }) or a string ("60s")"#
1438
1439        ; "slow timeout is not a duration"
1440    )]
1441    #[test_case(
1442        indoc! {r#"
1443            [scripts.setup.'@tool:foo']
1444            command = "my-command"
1445        "#},
1446        r#"invalid configuration script name: tool identifier not of the form "@tool:tool-name:identifier": `@tool:foo`"#
1447
1448        ; "invalid tool script name"
1449    )]
1450    #[test_case(
1451        indoc! {r#"
1452            [scripts.setup.'#foo']
1453            command = "my-command"
1454        "#},
1455        r"invalid configuration script name: invalid identifier `#foo`"
1456
1457        ; "invalid script name"
1458    )]
1459    #[test_case(
1460        indoc! {r#"
1461            [scripts.wrapper.foo]
1462            command = "my-command"
1463            target-runner = "not-a-valid-value"
1464        "#},
1465        r#"unknown variant `not-a-valid-value`, expected one of `ignore`, `overrides-wrapper`, `within-wrapper`, `around-wrapper`"#
1466
1467        ; "invalid target-runner value"
1468    )]
1469    #[test_case(
1470        indoc! {r#"
1471            [scripts.wrapper.foo]
1472            command = "my-command"
1473            target-runner = ["foo"]
1474        "#},
1475        r#"invalid type: sequence, expected a string"#
1476
1477        ; "target-runner is not a string"
1478    )]
1479    #[test_case(
1480        indoc! {r#"
1481            [scripts.setup.foo]
1482            command = {
1483                env = {},
1484                env = {},
1485            }
1486        "#},
1487        r#"duplicate key"#
1488
1489        ; "env is duplicate"
1490    )]
1491    #[test_case(
1492        indoc! {r#"
1493            [scripts.setup.foo]
1494            command = {
1495                command-line = "my-command",
1496                env = "not a map"
1497            }
1498        "#},
1499        r#"scripts.setup.foo.command.env: invalid type: string "not a map", expected a map of environment variable names to values"#
1500
1501        ; "env is not a map"
1502    )]
1503    #[test_case(
1504        indoc! {r#"
1505            [scripts.setup.foo]
1506            command = {
1507                command-line = "my-command",
1508                env = {
1509                    NEXTEST_RESERVED = "reserved",
1510                },
1511            }
1512        "#},
1513        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"#
1514
1515        ; "env containing key reserved for internal use"
1516    )]
1517    #[test_case(
1518        indoc! {r#"
1519            [scripts.setup.foo]
1520            command = {
1521                command-line = "my-command",
1522                env = {
1523                    42 = "answer",
1524                },
1525            }
1526        "#},
1527        r#"scripts.setup.foo.command.env: invalid value: string "42", expected a key that starts with a letter or underscore"#
1528
1529        ; "env containing key first character a digit"
1530    )]
1531    #[test_case(
1532        indoc! {r#"
1533            [scripts.setup.foo]
1534            command = {
1535                command-line = "my-command",
1536                env = {
1537                    " " = "some value",
1538                },
1539            }
1540        "#},
1541        r#"scripts.setup.foo.command.env: invalid value: string " ", expected a key that starts with a letter or underscore"#
1542
1543        ; "env containing key started with an unsupported characters"
1544    )]
1545    #[test_case(
1546        indoc! {r#"
1547            [scripts.setup.foo]
1548            command = {
1549                command-line = "my-command",
1550                env = {
1551                    "test=test" = "some value",
1552                },
1553            }
1554        "#},
1555        r#"scripts.setup.foo.command.env: invalid value: string "test=test", expected a key that consists solely of letters, digits, and underscores"#
1556
1557        ; "env containing key with unsupported characters"
1558    )]
1559    fn parse_scripts_invalid_deserialize(config_contents: &str, message: &str) {
1560        let workspace_dir = tempdir().unwrap();
1561
1562        let graph = temp_workspace(&workspace_dir, config_contents);
1563        let pcx = ParseContext::new(&graph);
1564
1565        let nextest_config_error = NextestConfig::from_sources(
1566            graph.workspace().root(),
1567            &pcx,
1568            None,
1569            &[][..],
1570            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1571        )
1572        .expect_err("config is invalid");
1573        let actual_message = DisplayErrorChain::new(nextest_config_error).to_string();
1574
1575        assert!(
1576            actual_message.contains(message),
1577            "nextest config error `{actual_message}` contains message `{message}`"
1578        );
1579    }
1580
1581    #[test_case(
1582        indoc! {r#"
1583            [scripts.setup.foo]
1584            command = "my-command"
1585
1586            [[profile.default.scripts]]
1587            setup = ["foo"]
1588        "#},
1589        "default",
1590        &[MietteJsonReport {
1591            message: "at least one of `platform` and `filter` must be specified".to_owned(),
1592            labels: vec![],
1593        }]
1594
1595        ; "neither platform nor filter specified"
1596    )]
1597    #[test_case(
1598        indoc! {r#"
1599            [scripts.setup.foo]
1600            command = "my-command"
1601
1602            [[profile.default.scripts]]
1603            platform = {}
1604            setup = ["foo"]
1605        "#},
1606        "default",
1607        &[MietteJsonReport {
1608            message: "at least one of `platform` and `filter` must be specified".to_owned(),
1609            labels: vec![],
1610        }]
1611
1612        ; "empty platform map"
1613    )]
1614    #[test_case(
1615        indoc! {r#"
1616            [scripts.setup.foo]
1617            command = "my-command"
1618
1619            [[profile.default.scripts]]
1620            platform = { host = 'cfg(target_os = "linux' }
1621            setup = ["foo"]
1622        "#},
1623        "default",
1624        &[MietteJsonReport {
1625            message: "error parsing cfg() expression".to_owned(),
1626            labels: vec![
1627                MietteJsonLabel { label: "expected one of `=`, `,`, `)` here".to_owned(), span: MietteJsonSpan { offset: 3, length: 1 } }
1628            ]
1629        }]
1630
1631        ; "invalid platform expression"
1632    )]
1633    #[test_case(
1634        indoc! {r#"
1635            [scripts.setup.foo]
1636            command = "my-command"
1637
1638            [[profile.ci.overrides]]
1639            filter = 'test(/foo)'
1640            setup = ["foo"]
1641        "#},
1642        "ci",
1643        &[MietteJsonReport {
1644            message: "expected close regex".to_owned(),
1645            labels: vec![
1646                MietteJsonLabel { label: "missing `/`".to_owned(), span: MietteJsonSpan { offset: 9, length: 0 } }
1647            ]
1648        }]
1649
1650        ; "invalid filterset"
1651    )]
1652    fn parse_scripts_invalid_compile(
1653        config_contents: &str,
1654        faulty_profile: &str,
1655        expected_reports: &[MietteJsonReport],
1656    ) {
1657        let workspace_dir = tempdir().unwrap();
1658
1659        let graph = temp_workspace(&workspace_dir, config_contents);
1660
1661        let pcx = ParseContext::new(&graph);
1662
1663        let error = NextestConfig::from_sources(
1664            graph.workspace().root(),
1665            &pcx,
1666            None,
1667            &[][..],
1668            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1669        )
1670        .expect_err("config is invalid");
1671        match error.kind() {
1672            ConfigParseErrorKind::CompileErrors(compile_errors) => {
1673                assert_eq!(
1674                    compile_errors.len(),
1675                    1,
1676                    "exactly one override error must be produced"
1677                );
1678                let error = compile_errors.first().unwrap();
1679                assert_eq!(
1680                    error.profile_name, faulty_profile,
1681                    "compile error profile matches"
1682                );
1683                let handler = miette::JSONReportHandler::new();
1684                let reports = error
1685                    .kind
1686                    .reports()
1687                    .map(|report| {
1688                        let mut out = String::new();
1689                        handler.render_report(&mut out, report.as_ref()).unwrap();
1690
1691                        let json_report: MietteJsonReport = serde_json::from_str(&out)
1692                            .unwrap_or_else(|err| {
1693                                panic!(
1694                                    "failed to deserialize JSON message produced by miette: {err}"
1695                                )
1696                            });
1697                        json_report
1698                    })
1699                    .collect::<Vec<_>>();
1700                assert_eq!(&reports, expected_reports, "reports match");
1701            }
1702            other => {
1703                panic!(
1704                    "for config error {other:?}, expected ConfigParseErrorKind::CompiledDataParseError"
1705                );
1706            }
1707        }
1708    }
1709
1710    #[test_case(
1711        indoc! {r#"
1712            [scripts.setup.'@tool:foo:bar']
1713            command = "my-command"
1714
1715            [[profile.ci.overrides]]
1716            setup = ["@tool:foo:bar"]
1717        "#},
1718        &["@tool:foo:bar"]
1719
1720        ; "tool config in main program")]
1721    fn parse_scripts_invalid_defined(config_contents: &str, expected_invalid_scripts: &[&str]) {
1722        let workspace_dir = tempdir().unwrap();
1723
1724        let graph = temp_workspace(&workspace_dir, config_contents);
1725
1726        let pcx = ParseContext::new(&graph);
1727
1728        let error = NextestConfig::from_sources(
1729            graph.workspace().root(),
1730            &pcx,
1731            None,
1732            &[][..],
1733            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1734        )
1735        .expect_err("config is invalid");
1736        match error.kind() {
1737            ConfigParseErrorKind::InvalidConfigScriptsDefined(scripts) => {
1738                assert_eq!(
1739                    scripts.len(),
1740                    expected_invalid_scripts.len(),
1741                    "correct number of scripts defined"
1742                );
1743                for (script, expected_script) in scripts.iter().zip(expected_invalid_scripts) {
1744                    assert_eq!(script.as_str(), *expected_script, "script name matches");
1745                }
1746            }
1747            other => {
1748                panic!(
1749                    "for config error {other:?}, expected ConfigParseErrorKind::InvalidConfigScriptsDefined"
1750                );
1751            }
1752        }
1753    }
1754
1755    #[test_case(
1756        indoc! {r#"
1757            [scripts.setup.'blarg']
1758            command = "my-command"
1759
1760            [[profile.ci.overrides]]
1761            setup = ["blarg"]
1762        "#},
1763        &["blarg"]
1764
1765        ; "non-tool config in tool")]
1766    fn parse_scripts_invalid_defined_by_tool(
1767        tool_config_contents: &str,
1768        expected_invalid_scripts: &[&str],
1769    ) {
1770        let workspace_dir = tempdir().unwrap();
1771        let graph = temp_workspace(&workspace_dir, "");
1772
1773        let tool_path = workspace_dir.child(".config/my-tool.toml");
1774        tool_path.write_str(tool_config_contents).unwrap();
1775        let tool_config_files = [ToolConfigFile {
1776            tool: tool_name("my-tool"),
1777            config_file: tool_path.to_path_buf(),
1778        }];
1779
1780        let pcx = ParseContext::new(&graph);
1781
1782        let error = NextestConfig::from_sources(
1783            graph.workspace().root(),
1784            &pcx,
1785            None,
1786            &tool_config_files,
1787            &btreeset! { ConfigExperimental::SetupScripts },
1788        )
1789        .expect_err("config is invalid");
1790        match error.kind() {
1791            ConfigParseErrorKind::InvalidConfigScriptsDefinedByTool(scripts) => {
1792                assert_eq!(
1793                    scripts.len(),
1794                    expected_invalid_scripts.len(),
1795                    "exactly one script must be defined"
1796                );
1797                for (script, expected_script) in scripts.iter().zip(expected_invalid_scripts) {
1798                    assert_eq!(script.as_str(), *expected_script, "script name matches");
1799                }
1800            }
1801            other => {
1802                panic!(
1803                    "for config error {other:?}, expected ConfigParseErrorKind::InvalidConfigScriptsDefinedByTool"
1804                );
1805            }
1806        }
1807    }
1808
1809    #[test_case(
1810        indoc! {r#"
1811            [scripts.setup.foo]
1812            command = 'echo foo'
1813
1814            [[profile.default.scripts]]
1815            platform = 'cfg(unix)'
1816            setup = ['bar']
1817
1818            [[profile.ci.scripts]]
1819            platform = 'cfg(unix)'
1820            setup = ['baz']
1821        "#},
1822        vec![
1823            ProfileUnknownScriptError {
1824                profile_name: "default".to_owned(),
1825                name: ScriptId::new("bar".into()).unwrap(),
1826            },
1827            ProfileUnknownScriptError {
1828                profile_name: "ci".to_owned(),
1829                name: ScriptId::new("baz".into()).unwrap(),
1830            },
1831        ],
1832        &["foo"]
1833
1834        ; "unknown scripts"
1835    )]
1836    fn parse_scripts_invalid_unknown(
1837        config_contents: &str,
1838        expected_errors: Vec<ProfileUnknownScriptError>,
1839        expected_known_scripts: &[&str],
1840    ) {
1841        let workspace_dir = tempdir().unwrap();
1842
1843        let graph = temp_workspace(&workspace_dir, config_contents);
1844
1845        let pcx = ParseContext::new(&graph);
1846
1847        let error = NextestConfig::from_sources(
1848            graph.workspace().root(),
1849            &pcx,
1850            None,
1851            &[][..],
1852            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1853        )
1854        .expect_err("config is invalid");
1855        match error.kind() {
1856            ConfigParseErrorKind::ProfileScriptErrors {
1857                errors,
1858                known_scripts,
1859            } => {
1860                let ProfileScriptErrors {
1861                    unknown_scripts,
1862                    wrong_script_types,
1863                    list_scripts_using_run_filters,
1864                } = &**errors;
1865                assert_eq!(wrong_script_types.len(), 0, "no wrong script types");
1866                assert_eq!(
1867                    list_scripts_using_run_filters.len(),
1868                    0,
1869                    "no scripts using run filters in list phase"
1870                );
1871                assert_eq!(
1872                    unknown_scripts.len(),
1873                    expected_errors.len(),
1874                    "correct number of errors"
1875                );
1876                for (error, expected_error) in unknown_scripts.iter().zip(expected_errors) {
1877                    assert_eq!(error, &expected_error, "error matches");
1878                }
1879                assert_eq!(
1880                    known_scripts.len(),
1881                    expected_known_scripts.len(),
1882                    "correct number of known scripts"
1883                );
1884                for (script, expected_script) in known_scripts.iter().zip(expected_known_scripts) {
1885                    assert_eq!(
1886                        script.as_str(),
1887                        *expected_script,
1888                        "known script name matches"
1889                    );
1890                }
1891            }
1892            other => {
1893                panic!(
1894                    "for config error {other:?}, expected ConfigParseErrorKind::ProfileScriptErrors"
1895                );
1896            }
1897        }
1898    }
1899
1900    #[test_case(
1901        indoc! {r#"
1902            [scripts.setup.setup-script]
1903            command = 'echo setup'
1904
1905            [scripts.wrapper.wrapper-script]
1906            command = 'echo wrapper'
1907
1908            [[profile.default.scripts]]
1909            platform = 'cfg(unix)'
1910            setup = ['wrapper-script']
1911            list-wrapper = 'setup-script'
1912
1913            [[profile.ci.scripts]]
1914            platform = 'cfg(unix)'
1915            setup = 'wrapper-script'
1916            run-wrapper = 'setup-script'
1917        "#},
1918        vec![
1919            ProfileWrongConfigScriptTypeError {
1920                profile_name: "default".to_owned(),
1921                name: ScriptId::new("wrapper-script".into()).unwrap(),
1922                attempted: ProfileScriptType::Setup,
1923                actual: ScriptType::Wrapper,
1924            },
1925            ProfileWrongConfigScriptTypeError {
1926                profile_name: "default".to_owned(),
1927                name: ScriptId::new("setup-script".into()).unwrap(),
1928                attempted: ProfileScriptType::ListWrapper,
1929                actual: ScriptType::Setup,
1930            },
1931            ProfileWrongConfigScriptTypeError {
1932                profile_name: "ci".to_owned(),
1933                name: ScriptId::new("wrapper-script".into()).unwrap(),
1934                attempted: ProfileScriptType::Setup,
1935                actual: ScriptType::Wrapper,
1936            },
1937            ProfileWrongConfigScriptTypeError {
1938                profile_name: "ci".to_owned(),
1939                name: ScriptId::new("setup-script".into()).unwrap(),
1940                attempted: ProfileScriptType::RunWrapper,
1941                actual: ScriptType::Setup,
1942            },
1943        ],
1944        &["setup-script", "wrapper-script"]
1945
1946        ; "wrong script types"
1947    )]
1948    fn parse_scripts_invalid_wrong_type(
1949        config_contents: &str,
1950        expected_errors: Vec<ProfileWrongConfigScriptTypeError>,
1951        expected_known_scripts: &[&str],
1952    ) {
1953        let workspace_dir = tempdir().unwrap();
1954
1955        let graph = temp_workspace(&workspace_dir, config_contents);
1956
1957        let pcx = ParseContext::new(&graph);
1958
1959        let error = NextestConfig::from_sources(
1960            graph.workspace().root(),
1961            &pcx,
1962            None,
1963            &[][..],
1964            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1965        )
1966        .expect_err("config is invalid");
1967        match error.kind() {
1968            ConfigParseErrorKind::ProfileScriptErrors {
1969                errors,
1970                known_scripts,
1971            } => {
1972                let ProfileScriptErrors {
1973                    unknown_scripts,
1974                    wrong_script_types,
1975                    list_scripts_using_run_filters,
1976                } = &**errors;
1977                assert_eq!(unknown_scripts.len(), 0, "no unknown scripts");
1978                assert_eq!(
1979                    list_scripts_using_run_filters.len(),
1980                    0,
1981                    "no scripts using run filters in list phase"
1982                );
1983                assert_eq!(
1984                    wrong_script_types.len(),
1985                    expected_errors.len(),
1986                    "correct number of errors"
1987                );
1988                for (error, expected_error) in wrong_script_types.iter().zip(expected_errors) {
1989                    assert_eq!(error, &expected_error, "error matches");
1990                }
1991                assert_eq!(
1992                    known_scripts.len(),
1993                    expected_known_scripts.len(),
1994                    "correct number of known scripts"
1995                );
1996                for (script, expected_script) in known_scripts.iter().zip(expected_known_scripts) {
1997                    assert_eq!(
1998                        script.as_str(),
1999                        *expected_script,
2000                        "known script name matches"
2001                    );
2002                }
2003            }
2004            other => {
2005                panic!(
2006                    "for config error {other:?}, expected ConfigParseErrorKind::ProfileScriptErrors"
2007                );
2008            }
2009        }
2010    }
2011
2012    #[test_case(
2013        indoc! {r#"
2014            [scripts.wrapper.list-script]
2015            command = 'echo list'
2016
2017            [[profile.default.scripts]]
2018            filter = 'test(hello)'
2019            list-wrapper = 'list-script'
2020
2021            [[profile.ci.scripts]]
2022            filter = 'test(world)'
2023            list-wrapper = 'list-script'
2024        "#},
2025        vec![
2026            ProfileListScriptUsesRunFiltersError {
2027                profile_name: "default".to_owned(),
2028                name: ScriptId::new("list-script".into()).unwrap(),
2029                script_type: ProfileScriptType::ListWrapper,
2030                filters: vec!["test(hello)".to_owned()].into_iter().collect(),
2031            },
2032            ProfileListScriptUsesRunFiltersError {
2033                profile_name: "ci".to_owned(),
2034                name: ScriptId::new("list-script".into()).unwrap(),
2035                script_type: ProfileScriptType::ListWrapper,
2036                filters: vec!["test(world)".to_owned()].into_iter().collect(),
2037            },
2038        ],
2039        &["list-script"]
2040
2041        ; "list scripts using run filters"
2042    )]
2043    fn parse_scripts_invalid_list_using_run_filters(
2044        config_contents: &str,
2045        expected_errors: Vec<ProfileListScriptUsesRunFiltersError>,
2046        expected_known_scripts: &[&str],
2047    ) {
2048        let workspace_dir = tempdir().unwrap();
2049
2050        let graph = temp_workspace(&workspace_dir, config_contents);
2051
2052        let pcx = ParseContext::new(&graph);
2053
2054        let error = NextestConfig::from_sources(
2055            graph.workspace().root(),
2056            &pcx,
2057            None,
2058            &[][..],
2059            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
2060        )
2061        .expect_err("config is invalid");
2062        match error.kind() {
2063            ConfigParseErrorKind::ProfileScriptErrors {
2064                errors,
2065                known_scripts,
2066            } => {
2067                let ProfileScriptErrors {
2068                    unknown_scripts,
2069                    wrong_script_types,
2070                    list_scripts_using_run_filters,
2071                } = &**errors;
2072                assert_eq!(unknown_scripts.len(), 0, "no unknown scripts");
2073                assert_eq!(wrong_script_types.len(), 0, "no wrong script types");
2074                assert_eq!(
2075                    list_scripts_using_run_filters.len(),
2076                    expected_errors.len(),
2077                    "correct number of errors"
2078                );
2079                for (error, expected_error) in
2080                    list_scripts_using_run_filters.iter().zip(expected_errors)
2081                {
2082                    assert_eq!(error, &expected_error, "error matches");
2083                }
2084                assert_eq!(
2085                    known_scripts.len(),
2086                    expected_known_scripts.len(),
2087                    "correct number of known scripts"
2088                );
2089                for (script, expected_script) in known_scripts.iter().zip(expected_known_scripts) {
2090                    assert_eq!(
2091                        script.as_str(),
2092                        *expected_script,
2093                        "known script name matches"
2094                    );
2095                }
2096            }
2097            other => {
2098                panic!(
2099                    "for config error {other:?}, expected ConfigParseErrorKind::ProfileScriptErrors"
2100                );
2101            }
2102        }
2103    }
2104
2105    #[test]
2106    fn test_parse_scripts_empty_sections() {
2107        let config_contents = indoc! {r#"
2108            [scripts.setup.foo]
2109            command = 'echo foo'
2110
2111            [[profile.default.scripts]]
2112            platform = 'cfg(unix)'
2113
2114            [[profile.ci.scripts]]
2115            platform = 'cfg(unix)'
2116        "#};
2117
2118        let workspace_dir = tempdir().unwrap();
2119
2120        let graph = temp_workspace(&workspace_dir, config_contents);
2121
2122        let pcx = ParseContext::new(&graph);
2123
2124        // The config should still be valid, just with warnings
2125        let result = NextestConfig::from_sources(
2126            graph.workspace().root(),
2127            &pcx,
2128            None,
2129            &[][..],
2130            &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
2131        );
2132
2133        match result {
2134            Ok(_config) => {
2135                // Config should be valid, warnings are just printed to stderr
2136                // The warnings we added should have been printed during config parsing
2137            }
2138            Err(e) => {
2139                panic!("Config should be valid but got error: {e:?}");
2140            }
2141        }
2142    }
2143}