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