nextest_runner/config/scripts/
imp.rs

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