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