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