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