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