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