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