1use super::{
5 ArchiveConfig, CompiledByProfile, CompiledData, CompiledDefaultFilter, ConfigExperimental,
6 CustomTestGroup, DefaultJunitImpl, DeserializedOverride, DeserializedProfileScriptConfig,
7 JunitConfig, JunitImpl, MaxFail, NextestVersionDeserialize, RetryPolicy, ScriptConfig,
8 ScriptId, SettingSource, SetupScripts, SlowTimeout, TestGroup, TestGroupConfig, TestSettings,
9 TestThreads, ThreadsRequired, ToolConfigFile, leak_timeout::LeakTimeout,
10};
11use crate::{
12 errors::{
13 ConfigParseError, ConfigParseErrorKind, ProfileNotFound, UnknownConfigScriptError,
14 UnknownTestGroupError, provided_by_tool,
15 },
16 list::TestList,
17 platform::BuildPlatforms,
18 reporter::{FinalStatusLevel, StatusLevel, TestOutputDisplay},
19};
20use camino::{Utf8Path, Utf8PathBuf};
21use config::{
22 Config, ConfigBuilder, ConfigError, File, FileFormat, FileSourceFile, builder::DefaultState,
23};
24use indexmap::IndexMap;
25use nextest_filtering::{EvalContext, ParseContext, TestQuery};
26use serde::Deserialize;
27use std::{
28 collections::{BTreeMap, BTreeSet, HashMap, hash_map},
29 sync::LazyLock,
30};
31use tracing::warn;
32
33#[inline]
35pub fn get_num_cpus() -> usize {
36 static NUM_CPUS: LazyLock<usize> =
37 LazyLock::new(|| match std::thread::available_parallelism() {
38 Ok(count) => count.into(),
39 Err(err) => {
40 warn!("unable to determine num-cpus ({err}), assuming 1 logical CPU");
41 1
42 }
43 });
44
45 *NUM_CPUS
46}
47
48#[derive(Clone, Debug)]
57pub struct NextestConfig {
58 workspace_root: Utf8PathBuf,
59 inner: NextestConfigImpl,
60 compiled: CompiledByProfile,
61}
62
63impl NextestConfig {
64 pub const CONFIG_PATH: &'static str = ".config/nextest.toml";
67
68 pub const DEFAULT_CONFIG: &'static str = include_str!("../../default-config.toml");
72
73 pub const ENVIRONMENT_PREFIX: &'static str = "NEXTEST";
75
76 pub const DEFAULT_PROFILE: &'static str = "default";
78
79 pub const DEFAULT_MIRI_PROFILE: &'static str = "default-miri";
81
82 pub const DEFAULT_PROFILES: &'static [&'static str] =
84 &[Self::DEFAULT_PROFILE, Self::DEFAULT_MIRI_PROFILE];
85
86 pub fn from_sources<'a, I>(
96 workspace_root: impl Into<Utf8PathBuf>,
97 pcx: &ParseContext<'_>,
98 config_file: Option<&Utf8Path>,
99 tool_config_files: impl IntoIterator<IntoIter = I>,
100 experimental: &BTreeSet<ConfigExperimental>,
101 ) -> Result<Self, ConfigParseError>
102 where
103 I: Iterator<Item = &'a ToolConfigFile> + DoubleEndedIterator,
104 {
105 Self::from_sources_impl(
106 workspace_root,
107 pcx,
108 config_file,
109 tool_config_files,
110 experimental,
111 |config_file, tool, unknown| {
112 let mut unknown_str = String::new();
113 if unknown.len() == 1 {
114 unknown_str.push(' ');
116 unknown_str.push_str(unknown.iter().next().unwrap());
117 } else {
118 for ignored_key in unknown {
119 unknown_str.push('\n');
120 unknown_str.push_str(" - ");
121 unknown_str.push_str(ignored_key);
122 }
123 }
124
125 warn!(
126 "ignoring unknown configuration keys in config file {config_file}{}:{unknown_str}",
127 provided_by_tool(tool),
128 )
129 },
130 )
131 }
132
133 fn from_sources_impl<'a, I>(
135 workspace_root: impl Into<Utf8PathBuf>,
136 pcx: &ParseContext<'_>,
137 config_file: Option<&Utf8Path>,
138 tool_config_files: impl IntoIterator<IntoIter = I>,
139 experimental: &BTreeSet<ConfigExperimental>,
140 mut unknown_callback: impl FnMut(&Utf8Path, Option<&str>, &BTreeSet<String>),
141 ) -> Result<Self, ConfigParseError>
142 where
143 I: Iterator<Item = &'a ToolConfigFile> + DoubleEndedIterator,
144 {
145 let workspace_root = workspace_root.into();
146 let tool_config_files_rev = tool_config_files.into_iter().rev();
147 let (inner, compiled) = Self::read_from_sources(
148 pcx,
149 &workspace_root,
150 config_file,
151 tool_config_files_rev,
152 experimental,
153 &mut unknown_callback,
154 )?;
155 Ok(Self {
156 workspace_root,
157 inner,
158 compiled,
159 })
160 }
161
162 #[cfg(test)]
164 pub(crate) fn default_config(workspace_root: impl Into<Utf8PathBuf>) -> Self {
165 use itertools::Itertools;
166
167 let config = Self::make_default_config()
168 .build()
169 .expect("default config is always valid");
170
171 let mut unknown = BTreeSet::new();
172 let deserialized: NextestConfigDeserialize =
173 serde_ignored::deserialize(config, |path: serde_ignored::Path| {
174 unknown.insert(path.to_string());
175 })
176 .expect("default config is always valid");
177
178 if !unknown.is_empty() {
181 panic!(
182 "found unknown keys in default config: {}",
183 unknown.iter().join(", ")
184 );
185 }
186
187 Self {
188 workspace_root: workspace_root.into(),
189 inner: deserialized.into_config_impl(),
190 compiled: CompiledByProfile::for_default_config(),
192 }
193 }
194
195 pub fn profile(&self, name: impl AsRef<str>) -> Result<EarlyProfile<'_>, ProfileNotFound> {
198 self.make_profile(name.as_ref())
199 }
200
201 fn read_from_sources<'a>(
206 pcx: &ParseContext<'_>,
207 workspace_root: &Utf8Path,
208 file: Option<&Utf8Path>,
209 tool_config_files_rev: impl Iterator<Item = &'a ToolConfigFile>,
210 experimental: &BTreeSet<ConfigExperimental>,
211 unknown_callback: &mut impl FnMut(&Utf8Path, Option<&str>, &BTreeSet<String>),
212 ) -> Result<(NextestConfigImpl, CompiledByProfile), ConfigParseError> {
213 let mut composite_builder = Self::make_default_config();
215
216 let mut compiled = CompiledByProfile::for_default_config();
219
220 let mut known_groups = BTreeSet::new();
221 let mut known_scripts = BTreeSet::new();
222
223 for ToolConfigFile { config_file, tool } in tool_config_files_rev {
225 let source = File::new(config_file.as_str(), FileFormat::Toml);
226 Self::deserialize_individual_config(
227 pcx,
228 workspace_root,
229 config_file,
230 Some(tool),
231 source.clone(),
232 &mut compiled,
233 experimental,
234 unknown_callback,
235 &mut known_groups,
236 &mut known_scripts,
237 )?;
238
239 composite_builder = composite_builder.add_source(source);
241 }
242
243 let (config_file, source) = match file {
245 Some(file) => (file.to_owned(), File::new(file.as_str(), FileFormat::Toml)),
246 None => {
247 let config_file = workspace_root.join(Self::CONFIG_PATH);
248 let source = File::new(config_file.as_str(), FileFormat::Toml).required(false);
249 (config_file, source)
250 }
251 };
252
253 Self::deserialize_individual_config(
254 pcx,
255 workspace_root,
256 &config_file,
257 None,
258 source.clone(),
259 &mut compiled,
260 experimental,
261 unknown_callback,
262 &mut known_groups,
263 &mut known_scripts,
264 )?;
265
266 composite_builder = composite_builder.add_source(source);
267
268 let (config, _unknown) = Self::build_and_deserialize_config(&composite_builder)
271 .map_err(|kind| ConfigParseError::new(config_file, None, kind))?;
272
273 compiled.default.reverse();
275 for data in compiled.other.values_mut() {
276 data.reverse();
277 }
278
279 Ok((config.into_config_impl(), compiled))
280 }
281
282 #[expect(clippy::too_many_arguments)]
283 fn deserialize_individual_config(
284 pcx: &ParseContext<'_>,
285 workspace_root: &Utf8Path,
286 config_file: &Utf8Path,
287 tool: Option<&str>,
288 source: File<FileSourceFile, FileFormat>,
289 compiled_out: &mut CompiledByProfile,
290 experimental: &BTreeSet<ConfigExperimental>,
291 unknown_callback: &mut impl FnMut(&Utf8Path, Option<&str>, &BTreeSet<String>),
292 known_groups: &mut BTreeSet<CustomTestGroup>,
293 known_scripts: &mut BTreeSet<ScriptId>,
294 ) -> Result<(), ConfigParseError> {
295 let default_builder = Self::make_default_config();
298 let this_builder = default_builder.add_source(source);
299 let (this_config, unknown) = Self::build_and_deserialize_config(&this_builder)
300 .map_err(|kind| ConfigParseError::new(config_file, tool, kind))?;
301
302 if !unknown.is_empty() {
303 unknown_callback(config_file, tool, &unknown);
304 }
305
306 let (valid_groups, invalid_groups): (BTreeSet<_>, _) =
308 this_config.test_groups.keys().cloned().partition(|group| {
309 if let Some(tool) = tool {
310 group
312 .as_identifier()
313 .tool_components()
314 .is_some_and(|(tool_name, _)| tool_name == tool)
315 } else {
316 !group.as_identifier().is_tool_identifier()
318 }
319 });
320
321 if !invalid_groups.is_empty() {
322 let kind = if tool.is_some() {
323 ConfigParseErrorKind::InvalidTestGroupsDefinedByTool(invalid_groups)
324 } else {
325 ConfigParseErrorKind::InvalidTestGroupsDefined(invalid_groups)
326 };
327 return Err(ConfigParseError::new(config_file, tool, kind));
328 }
329
330 known_groups.extend(valid_groups);
331
332 if !this_config.scripts.is_empty()
334 && !experimental.contains(&ConfigExperimental::SetupScripts)
335 {
336 return Err(ConfigParseError::new(
337 config_file,
338 tool,
339 ConfigParseErrorKind::ExperimentalFeatureNotEnabled {
340 feature: ConfigExperimental::SetupScripts,
341 },
342 ));
343 }
344
345 let (valid_scripts, invalid_scripts): (BTreeSet<_>, _) =
347 this_config.scripts.keys().cloned().partition(|script| {
348 if let Some(tool) = tool {
349 script
351 .as_identifier()
352 .tool_components()
353 .is_some_and(|(tool_name, _)| tool_name == tool)
354 } else {
355 !script.as_identifier().is_tool_identifier()
357 }
358 });
359
360 if !invalid_scripts.is_empty() {
361 let kind = if tool.is_some() {
362 ConfigParseErrorKind::InvalidConfigScriptsDefinedByTool(invalid_scripts)
363 } else {
364 ConfigParseErrorKind::InvalidConfigScriptsDefined(invalid_scripts)
365 };
366 return Err(ConfigParseError::new(config_file, tool, kind));
367 }
368
369 known_scripts.extend(valid_scripts);
370
371 let this_config = this_config.into_config_impl();
372
373 let unknown_default_profiles: Vec<_> = this_config
374 .all_profiles()
375 .filter(|p| p.starts_with("default-") && !NextestConfig::DEFAULT_PROFILES.contains(p))
376 .collect();
377 if !unknown_default_profiles.is_empty() {
378 warn!(
379 "unknown profiles in the reserved `default-` namespace in config file {}{}:",
380 config_file
381 .strip_prefix(workspace_root)
382 .unwrap_or(config_file),
383 provided_by_tool(tool),
384 );
385
386 for profile in unknown_default_profiles {
387 warn!(" {profile}");
388 }
389 }
390
391 let this_compiled = CompiledByProfile::new(pcx, &this_config)
393 .map_err(|kind| ConfigParseError::new(config_file, tool, kind))?;
394
395 let mut unknown_group_errors = Vec::new();
397 let mut check_test_group = |profile_name: &str, test_group: Option<&TestGroup>| {
398 if let Some(TestGroup::Custom(group)) = test_group {
399 if !known_groups.contains(group) {
400 unknown_group_errors.push(UnknownTestGroupError {
401 profile_name: profile_name.to_owned(),
402 name: TestGroup::Custom(group.clone()),
403 });
404 }
405 }
406 };
407
408 this_compiled
409 .default
410 .overrides
411 .iter()
412 .for_each(|override_| {
413 check_test_group("default", override_.data.test_group.as_ref());
414 });
415
416 this_compiled.other.iter().for_each(|(profile_name, data)| {
418 data.overrides.iter().for_each(|override_| {
419 check_test_group(profile_name, override_.data.test_group.as_ref());
420 });
421 });
422
423 if !unknown_group_errors.is_empty() {
425 let known_groups = TestGroup::make_all_groups(known_groups.iter().cloned()).collect();
426 return Err(ConfigParseError::new(
427 config_file,
428 tool,
429 ConfigParseErrorKind::UnknownTestGroups {
430 errors: unknown_group_errors,
431 known_groups,
432 },
433 ));
434 }
435
436 let mut unknown_script_errors = Vec::new();
438 let mut check_script_ids = |profile_name: &str, scripts: &[ScriptId]| {
439 if !scripts.is_empty() && !experimental.contains(&ConfigExperimental::SetupScripts) {
440 return Err(ConfigParseError::new(
441 config_file,
442 tool,
443 ConfigParseErrorKind::ExperimentalFeatureNotEnabled {
444 feature: ConfigExperimental::SetupScripts,
445 },
446 ));
447 }
448 for script in scripts {
449 if !known_scripts.contains(script) {
450 unknown_script_errors.push(UnknownConfigScriptError {
451 profile_name: profile_name.to_owned(),
452 name: script.clone(),
453 });
454 }
455 }
456
457 Ok(())
458 };
459
460 this_compiled
461 .default
462 .scripts
463 .iter()
464 .try_for_each(|scripts| check_script_ids("default", &scripts.setup))?;
465 this_compiled
466 .other
467 .iter()
468 .try_for_each(|(profile_name, data)| {
469 data.scripts
470 .iter()
471 .try_for_each(|scripts| check_script_ids(profile_name, &scripts.setup))
472 })?;
473
474 if !unknown_script_errors.is_empty() {
476 let known_scripts = known_scripts.iter().cloned().collect();
477 return Err(ConfigParseError::new(
478 config_file,
479 tool,
480 ConfigParseErrorKind::UnknownConfigScripts {
481 errors: unknown_script_errors,
482 known_scripts,
483 },
484 ));
485 }
486
487 compiled_out.default.extend_reverse(this_compiled.default);
490 for (name, mut data) in this_compiled.other {
491 match compiled_out.other.entry(name) {
492 hash_map::Entry::Vacant(entry) => {
493 data.reverse();
495 entry.insert(data);
496 }
497 hash_map::Entry::Occupied(mut entry) => {
498 entry.get_mut().extend_reverse(data);
500 }
501 }
502 }
503
504 Ok(())
505 }
506
507 fn make_default_config() -> ConfigBuilder<DefaultState> {
508 Config::builder().add_source(File::from_str(Self::DEFAULT_CONFIG, FileFormat::Toml))
509 }
510
511 fn make_profile(&self, name: &str) -> Result<EarlyProfile<'_>, ProfileNotFound> {
512 let custom_profile = self.inner.get_profile(name)?;
513
514 let mut store_dir = self.workspace_root.join(&self.inner.store.dir);
516 store_dir.push(name);
517
518 let compiled_data = match self.compiled.other.get(name) {
520 Some(data) => data.clone().chain(self.compiled.default.clone()),
521 None => self.compiled.default.clone(),
522 };
523
524 Ok(EarlyProfile {
525 name: name.to_owned(),
526 store_dir,
527 default_profile: &self.inner.default_profile,
528 custom_profile,
529 test_groups: &self.inner.test_groups,
530 scripts: &self.inner.scripts,
531 compiled_data,
532 })
533 }
534
535 fn build_and_deserialize_config(
537 builder: &ConfigBuilder<DefaultState>,
538 ) -> Result<(NextestConfigDeserialize, BTreeSet<String>), ConfigParseErrorKind> {
539 let config = builder
540 .build_cloned()
541 .map_err(|error| ConfigParseErrorKind::BuildError(Box::new(error)))?;
542
543 let mut ignored = BTreeSet::new();
544 let mut cb = |path: serde_ignored::Path| {
545 ignored.insert(path.to_string());
546 };
547 let ignored_de = serde_ignored::Deserializer::new(config, &mut cb);
548 let config: NextestConfigDeserialize = serde_path_to_error::deserialize(ignored_de)
549 .map_err(|error| {
550 let path = error.path().clone();
554 let config_error = error.into_inner();
555 let error = match config_error {
556 ConfigError::At { error, .. } => *error,
557 other => other,
558 };
559 ConfigParseErrorKind::DeserializeError(Box::new(serde_path_to_error::Error::new(
560 path, error,
561 )))
562 })?;
563
564 Ok((config, ignored))
565 }
566}
567
568#[derive(Clone, Debug, Default)]
570pub(super) struct PreBuildPlatform {}
571
572#[derive(Clone, Debug)]
574pub(crate) struct FinalConfig {
575 pub(super) host_eval: bool,
577 pub(super) host_test_eval: bool,
580 pub(super) target_eval: bool,
583}
584
585pub struct EarlyProfile<'cfg> {
590 name: String,
591 store_dir: Utf8PathBuf,
592 default_profile: &'cfg DefaultProfileImpl,
593 custom_profile: Option<&'cfg CustomProfileImpl>,
594 test_groups: &'cfg BTreeMap<CustomTestGroup, TestGroupConfig>,
595 scripts: &'cfg IndexMap<ScriptId, ScriptConfig>,
597 pub(super) compiled_data: CompiledData<PreBuildPlatform>,
599}
600
601impl<'cfg> EarlyProfile<'cfg> {
602 pub fn store_dir(&self) -> &Utf8Path {
604 &self.store_dir
605 }
606
607 pub fn test_group_config(&self) -> &'cfg BTreeMap<CustomTestGroup, TestGroupConfig> {
609 self.test_groups
610 }
611
612 pub fn apply_build_platforms(
617 self,
618 build_platforms: &BuildPlatforms,
619 ) -> EvaluatableProfile<'cfg> {
620 let compiled_data = self.compiled_data.apply_build_platforms(build_platforms);
621
622 let resolved_default_filter = {
623 let found_filter = compiled_data
625 .overrides
626 .iter()
627 .find_map(|override_data| override_data.default_filter_if_matches_platform());
628 found_filter.unwrap_or_else(|| {
629 compiled_data
632 .profile_default_filter
633 .as_ref()
634 .expect("compiled data always has default set")
635 })
636 }
637 .clone();
638
639 EvaluatableProfile {
640 name: self.name,
641 store_dir: self.store_dir,
642 default_profile: self.default_profile,
643 custom_profile: self.custom_profile,
644 scripts: self.scripts,
645 test_groups: self.test_groups,
646 compiled_data,
647 resolved_default_filter,
648 }
649 }
650}
651
652#[derive(Clone, Debug)]
656pub struct EvaluatableProfile<'cfg> {
657 name: String,
658 store_dir: Utf8PathBuf,
659 default_profile: &'cfg DefaultProfileImpl,
660 custom_profile: Option<&'cfg CustomProfileImpl>,
661 test_groups: &'cfg BTreeMap<CustomTestGroup, TestGroupConfig>,
662 scripts: &'cfg IndexMap<ScriptId, ScriptConfig>,
664 pub(super) compiled_data: CompiledData<FinalConfig>,
666 resolved_default_filter: CompiledDefaultFilter,
669}
670
671impl<'cfg> EvaluatableProfile<'cfg> {
672 pub fn name(&self) -> &str {
674 &self.name
675 }
676
677 pub fn store_dir(&self) -> &Utf8Path {
679 &self.store_dir
680 }
681
682 pub fn filterset_ecx(&self) -> EvalContext<'_> {
684 EvalContext {
685 default_filter: &self.default_filter().expr,
686 }
687 }
688
689 pub fn default_filter(&self) -> &CompiledDefaultFilter {
691 &self.resolved_default_filter
692 }
693
694 pub fn test_group_config(&self) -> &'cfg BTreeMap<CustomTestGroup, TestGroupConfig> {
696 self.test_groups
697 }
698
699 pub fn script_config(&self) -> &'cfg IndexMap<ScriptId, ScriptConfig> {
701 self.scripts
702 }
703
704 pub fn retries(&self) -> RetryPolicy {
706 self.custom_profile
707 .and_then(|profile| profile.retries)
708 .unwrap_or(self.default_profile.retries)
709 }
710
711 pub fn test_threads(&self) -> TestThreads {
713 self.custom_profile
714 .and_then(|profile| profile.test_threads)
715 .unwrap_or(self.default_profile.test_threads)
716 }
717
718 pub fn threads_required(&self) -> ThreadsRequired {
720 self.custom_profile
721 .and_then(|profile| profile.threads_required)
722 .unwrap_or(self.default_profile.threads_required)
723 }
724
725 pub fn run_extra_args(&self) -> &'cfg [String] {
727 self.custom_profile
728 .and_then(|profile| profile.run_extra_args.as_deref())
729 .unwrap_or(&self.default_profile.run_extra_args)
730 }
731
732 pub fn slow_timeout(&self) -> SlowTimeout {
734 self.custom_profile
735 .and_then(|profile| profile.slow_timeout)
736 .unwrap_or(self.default_profile.slow_timeout)
737 }
738
739 pub fn leak_timeout(&self) -> LeakTimeout {
742 self.custom_profile
743 .and_then(|profile| profile.leak_timeout)
744 .unwrap_or(self.default_profile.leak_timeout)
745 }
746
747 pub fn status_level(&self) -> StatusLevel {
749 self.custom_profile
750 .and_then(|profile| profile.status_level)
751 .unwrap_or(self.default_profile.status_level)
752 }
753
754 pub fn final_status_level(&self) -> FinalStatusLevel {
756 self.custom_profile
757 .and_then(|profile| profile.final_status_level)
758 .unwrap_or(self.default_profile.final_status_level)
759 }
760
761 pub fn failure_output(&self) -> TestOutputDisplay {
763 self.custom_profile
764 .and_then(|profile| profile.failure_output)
765 .unwrap_or(self.default_profile.failure_output)
766 }
767
768 pub fn success_output(&self) -> TestOutputDisplay {
770 self.custom_profile
771 .and_then(|profile| profile.success_output)
772 .unwrap_or(self.default_profile.success_output)
773 }
774
775 pub fn max_fail(&self) -> MaxFail {
777 self.custom_profile
778 .and_then(|profile| profile.max_fail)
779 .unwrap_or(self.default_profile.max_fail)
780 }
781
782 pub fn archive_config(&self) -> &'cfg ArchiveConfig {
784 self.custom_profile
785 .and_then(|profile| profile.archive.as_ref())
786 .unwrap_or(&self.default_profile.archive)
787 }
788
789 pub fn setup_scripts(&self, test_list: &TestList<'_>) -> SetupScripts<'_> {
791 SetupScripts::new(self, test_list)
792 }
793
794 pub fn settings_for(&self, query: &TestQuery<'_>) -> TestSettings {
796 TestSettings::new(self, query)
797 }
798
799 pub(crate) fn settings_with_source_for(
801 &self,
802 query: &TestQuery<'_>,
803 ) -> TestSettings<SettingSource<'_>> {
804 TestSettings::new(self, query)
805 }
806
807 pub fn junit(&self) -> Option<JunitConfig<'cfg>> {
809 JunitConfig::new(
810 self.store_dir(),
811 self.custom_profile.map(|p| &p.junit),
812 &self.default_profile.junit,
813 )
814 }
815
816 #[cfg(test)]
817 pub(super) fn custom_profile(&self) -> Option<&'cfg CustomProfileImpl> {
818 self.custom_profile
819 }
820}
821
822#[derive(Clone, Debug)]
823pub(super) struct NextestConfigImpl {
824 store: StoreConfigImpl,
825 test_groups: BTreeMap<CustomTestGroup, TestGroupConfig>,
826 scripts: IndexMap<ScriptId, ScriptConfig>,
827 default_profile: DefaultProfileImpl,
828 other_profiles: HashMap<String, CustomProfileImpl>,
829}
830
831impl NextestConfigImpl {
832 fn get_profile(&self, profile: &str) -> Result<Option<&CustomProfileImpl>, ProfileNotFound> {
833 let custom_profile = match profile {
834 NextestConfig::DEFAULT_PROFILE => None,
835 other => Some(
836 self.other_profiles
837 .get(other)
838 .ok_or_else(|| ProfileNotFound::new(profile, self.all_profiles()))?,
839 ),
840 };
841 Ok(custom_profile)
842 }
843
844 fn all_profiles(&self) -> impl Iterator<Item = &str> {
845 self.other_profiles
846 .keys()
847 .map(|key| key.as_str())
848 .chain(std::iter::once(NextestConfig::DEFAULT_PROFILE))
849 }
850
851 pub(super) fn default_profile(&self) -> &DefaultProfileImpl {
852 &self.default_profile
853 }
854
855 pub(super) fn other_profiles(&self) -> impl Iterator<Item = (&str, &CustomProfileImpl)> {
856 self.other_profiles
857 .iter()
858 .map(|(key, value)| (key.as_str(), value))
859 }
860}
861
862#[derive(Clone, Debug, Deserialize)]
864#[serde(rename_all = "kebab-case")]
865struct NextestConfigDeserialize {
866 store: StoreConfigImpl,
867
868 #[expect(unused)]
871 #[serde(default)]
872 nextest_version: Option<NextestVersionDeserialize>,
873 #[expect(unused)]
874 #[serde(default)]
875 experimental: BTreeSet<String>,
876
877 #[serde(default)]
878 test_groups: BTreeMap<CustomTestGroup, TestGroupConfig>,
879 #[serde(default, rename = "script")]
880 scripts: IndexMap<ScriptId, ScriptConfig>,
881 #[serde(rename = "profile")]
882 profiles: HashMap<String, CustomProfileImpl>,
883}
884
885impl NextestConfigDeserialize {
886 fn into_config_impl(mut self) -> NextestConfigImpl {
887 let p = self
888 .profiles
889 .remove("default")
890 .expect("default profile should exist");
891 let default_profile = DefaultProfileImpl::new(p);
892
893 NextestConfigImpl {
894 store: self.store,
895 default_profile,
896 test_groups: self.test_groups,
897 scripts: self.scripts,
898 other_profiles: self.profiles,
899 }
900 }
901}
902
903#[derive(Clone, Debug, Deserialize)]
904#[serde(rename_all = "kebab-case")]
905struct StoreConfigImpl {
906 dir: Utf8PathBuf,
907}
908
909#[derive(Clone, Debug)]
910pub(super) struct DefaultProfileImpl {
911 default_filter: String,
912 test_threads: TestThreads,
913 threads_required: ThreadsRequired,
914 run_extra_args: Vec<String>,
915 retries: RetryPolicy,
916 status_level: StatusLevel,
917 final_status_level: FinalStatusLevel,
918 failure_output: TestOutputDisplay,
919 success_output: TestOutputDisplay,
920 max_fail: MaxFail,
921 slow_timeout: SlowTimeout,
922 leak_timeout: LeakTimeout,
923 overrides: Vec<DeserializedOverride>,
924 scripts: Vec<DeserializedProfileScriptConfig>,
925 junit: DefaultJunitImpl,
926 archive: ArchiveConfig,
927}
928
929impl DefaultProfileImpl {
930 fn new(p: CustomProfileImpl) -> Self {
931 Self {
932 default_filter: p
933 .default_filter
934 .expect("default-filter present in default profile"),
935 test_threads: p
936 .test_threads
937 .expect("test-threads present in default profile"),
938 threads_required: p
939 .threads_required
940 .expect("threads-required present in default profile"),
941 run_extra_args: p
942 .run_extra_args
943 .expect("run-extra-args present in default profile"),
944 retries: p.retries.expect("retries present in default profile"),
945 status_level: p
946 .status_level
947 .expect("status-level present in default profile"),
948 final_status_level: p
949 .final_status_level
950 .expect("final-status-level present in default profile"),
951 failure_output: p
952 .failure_output
953 .expect("failure-output present in default profile"),
954 success_output: p
955 .success_output
956 .expect("success-output present in default profile"),
957 max_fail: p.max_fail.expect("fail-fast present in default profile"),
958 slow_timeout: p
959 .slow_timeout
960 .expect("slow-timeout present in default profile"),
961 leak_timeout: p
962 .leak_timeout
963 .expect("leak-timeout present in default profile"),
964 overrides: p.overrides,
965 scripts: p.scripts,
966 junit: DefaultJunitImpl::for_default_profile(p.junit),
967 archive: p.archive.expect("archive present in default profile"),
968 }
969 }
970
971 pub(super) fn default_filter(&self) -> &str {
972 &self.default_filter
973 }
974
975 pub(super) fn overrides(&self) -> &[DeserializedOverride] {
976 &self.overrides
977 }
978
979 pub(super) fn setup_scripts(&self) -> &[DeserializedProfileScriptConfig] {
980 &self.scripts
981 }
982}
983
984#[derive(Clone, Debug, Deserialize)]
985#[serde(rename_all = "kebab-case")]
986pub(super) struct CustomProfileImpl {
987 #[serde(default)]
989 default_filter: Option<String>,
990 #[serde(default, deserialize_with = "super::deserialize_retry_policy")]
991 retries: Option<RetryPolicy>,
992 #[serde(default)]
993 test_threads: Option<TestThreads>,
994 #[serde(default)]
995 threads_required: Option<ThreadsRequired>,
996 #[serde(default)]
997 run_extra_args: Option<Vec<String>>,
998 #[serde(default)]
999 status_level: Option<StatusLevel>,
1000 #[serde(default)]
1001 final_status_level: Option<FinalStatusLevel>,
1002 #[serde(default)]
1003 failure_output: Option<TestOutputDisplay>,
1004 #[serde(default)]
1005 success_output: Option<TestOutputDisplay>,
1006 #[serde(
1007 default,
1008 rename = "fail-fast",
1009 deserialize_with = "super::deserialize_fail_fast"
1010 )]
1011 max_fail: Option<MaxFail>,
1012 #[serde(default, deserialize_with = "super::deserialize_slow_timeout")]
1013 slow_timeout: Option<SlowTimeout>,
1014 #[serde(default, deserialize_with = "super::deserialize_leak_timeout")]
1015 leak_timeout: Option<LeakTimeout>,
1016 #[serde(default)]
1017 overrides: Vec<DeserializedOverride>,
1018 #[serde(default)]
1019 scripts: Vec<DeserializedProfileScriptConfig>,
1020 #[serde(default)]
1021 junit: JunitImpl,
1022 #[serde(default)]
1023 archive: Option<ArchiveConfig>,
1024}
1025
1026impl CustomProfileImpl {
1027 #[cfg(test)]
1028 pub(super) fn test_threads(&self) -> Option<TestThreads> {
1029 self.test_threads
1030 }
1031
1032 pub(super) fn default_filter(&self) -> Option<&str> {
1033 self.default_filter.as_deref()
1034 }
1035
1036 pub(super) fn overrides(&self) -> &[DeserializedOverride] {
1037 &self.overrides
1038 }
1039
1040 pub(super) fn scripts(&self) -> &[DeserializedProfileScriptConfig] {
1041 &self.scripts
1042 }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048 use crate::config::test_helpers::*;
1049 use camino_tempfile::tempdir;
1050
1051 #[test]
1052 fn default_config_is_valid() {
1053 let default_config = NextestConfig::default_config("foo");
1054 default_config
1055 .profile(NextestConfig::DEFAULT_PROFILE)
1056 .expect("default profile should exist");
1057 }
1058
1059 #[test]
1060 fn ignored_keys() {
1061 let config_contents = r#"
1062 ignored1 = "test"
1063
1064 [profile.default]
1065 retries = 3
1066 ignored2 = "hi"
1067
1068 [[profile.default.overrides]]
1069 filter = 'test(test_foo)'
1070 retries = 20
1071 ignored3 = 42
1072 "#;
1073
1074 let tool_config_contents = r#"
1075 [store]
1076 ignored4 = 20
1077
1078 [profile.default]
1079 retries = 4
1080 ignored5 = false
1081
1082 [profile.tool]
1083 retries = 12
1084
1085 [[profile.tool.overrides]]
1086 filter = 'test(test_baz)'
1087 retries = 22
1088 ignored6 = 6.5
1089 "#;
1090
1091 let workspace_dir = tempdir().unwrap();
1092
1093 let graph = temp_workspace(&workspace_dir, config_contents);
1094 let workspace_root = graph.workspace().root();
1095 let tool_path = workspace_root.join(".config/tool.toml");
1096 std::fs::write(&tool_path, tool_config_contents).unwrap();
1097
1098 let pcx = ParseContext::new(&graph);
1099
1100 let mut unknown_keys = HashMap::new();
1101
1102 let _ = NextestConfig::from_sources_impl(
1103 workspace_root,
1104 &pcx,
1105 None,
1106 &[ToolConfigFile {
1107 tool: "my-tool".to_owned(),
1108 config_file: tool_path,
1109 }][..],
1110 &Default::default(),
1111 |_path, tool, ignored| {
1112 unknown_keys.insert(tool.map(|s| s.to_owned()), ignored.clone());
1113 },
1114 )
1115 .expect("config is valid");
1116
1117 assert_eq!(
1118 unknown_keys.len(),
1119 2,
1120 "there are two files with unknown keys"
1121 );
1122
1123 let keys = unknown_keys
1124 .remove(&None)
1125 .expect("unknown keys for .config/nextest.toml");
1126 assert_eq!(
1127 keys,
1128 maplit::btreeset! {
1129 "ignored1".to_owned(),
1130 "profile.default.ignored2".to_owned(),
1131 "profile.default.overrides.0.ignored3".to_owned(),
1132 }
1133 );
1134
1135 let keys = unknown_keys
1136 .remove(&Some("my-tool".to_owned()))
1137 .expect("unknown keys for my-tool");
1138 assert_eq!(
1139 keys,
1140 maplit::btreeset! {
1141 "store.ignored4".to_owned(),
1142 "profile.default.ignored5".to_owned(),
1143 "profile.tool.overrides.0.ignored6".to_owned(),
1144 }
1145 );
1146 }
1147}