nextest_runner/config/
config_impl.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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/// Gets the number of available CPUs and caches the value.
34#[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/// Overall configuration for nextest.
49///
50/// This is the root data structure for nextest configuration. Most runner-specific configuration is
51/// managed through [profiles](EvaluatableProfile), obtained through the [`profile`](Self::profile)
52/// method.
53///
54/// For more about configuration, see [_Configuration_](https://nexte.st/docs/configuration) in the
55/// nextest book.
56#[derive(Clone, Debug)]
57pub struct NextestConfig {
58    workspace_root: Utf8PathBuf,
59    inner: NextestConfigImpl,
60    compiled: CompiledByProfile,
61}
62
63impl NextestConfig {
64    /// The default location of the config within the path: `.config/nextest.toml`, used to read the
65    /// config from the given directory.
66    pub const CONFIG_PATH: &'static str = ".config/nextest.toml";
67
68    /// Contains the default config as a TOML file.
69    ///
70    /// Repository-specific configuration is layered on top of the default config.
71    pub const DEFAULT_CONFIG: &'static str = include_str!("../../default-config.toml");
72
73    /// Environment configuration uses this prefix, plus a _.
74    pub const ENVIRONMENT_PREFIX: &'static str = "NEXTEST";
75
76    /// The name of the default profile.
77    pub const DEFAULT_PROFILE: &'static str = "default";
78
79    /// The name of the default profile used for miri.
80    pub const DEFAULT_MIRI_PROFILE: &'static str = "default-miri";
81
82    /// A list containing the names of the Nextest defined reserved profile names.
83    pub const DEFAULT_PROFILES: &'static [&'static str] =
84        &[Self::DEFAULT_PROFILE, Self::DEFAULT_MIRI_PROFILE];
85
86    /// Reads the nextest config from the given file, or if not specified from `.config/nextest.toml`
87    /// in the workspace root.
88    ///
89    /// `tool_config_files` are lower priority than `config_file` but higher priority than the
90    /// default config. Files in `tool_config_files` that come earlier are higher priority than those
91    /// that come later.
92    ///
93    /// If no config files are specified and this file doesn't have `.config/nextest.toml`, uses the
94    /// default config options.
95    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                    // Print this on the same line.
115                    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    // A custom unknown_callback can be passed in while testing.
134    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    /// Returns the default nextest config.
163    #[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        // Make sure there aren't any unknown keys in the default config, since it is
179        // embedded/shipped with this binary.
180        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            // The default config has no overrides or special settings.
191            compiled: CompiledByProfile::for_default_config(),
192        }
193    }
194
195    /// Returns the profile with the given name, or an error if a profile was
196    /// specified but not found.
197    pub fn profile(&self, name: impl AsRef<str>) -> Result<EarlyProfile<'_>, ProfileNotFound> {
198        self.make_profile(name.as_ref())
199    }
200
201    // ---
202    // Helper methods
203    // ---
204
205    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        // First, get the default config.
214        let mut composite_builder = Self::make_default_config();
215
216        // Overrides are handled additively.
217        // Note that they're stored in reverse order here, and are flipped over at the end.
218        let mut compiled = CompiledByProfile::for_default_config();
219
220        let mut known_groups = BTreeSet::new();
221        let mut known_scripts = BTreeSet::new();
222
223        // Next, merge in tool configs.
224        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            // This is the final, composite builder used at the end.
240            composite_builder = composite_builder.add_source(source);
241        }
242
243        // Next, merge in the config from the given file.
244        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        // The unknown set is ignored here because any values in it have already been reported in
269        // deserialize_individual_config.
270        let (config, _unknown) = Self::build_and_deserialize_config(&composite_builder)
271            .map_err(|kind| ConfigParseError::new(config_file, None, kind))?;
272
273        // Reverse all the compiled data at the end.
274        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        // Try building default builder + this file to get good error attribution and handle
296        // overrides additively.
297        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        // Check that test groups are named as expected.
307        let (valid_groups, invalid_groups): (BTreeSet<_>, _) =
308            this_config.test_groups.keys().cloned().partition(|group| {
309                if let Some(tool) = tool {
310                    // The first component must be the tool name.
311                    group
312                        .as_identifier()
313                        .tool_components()
314                        .is_some_and(|(tool_name, _)| tool_name == tool)
315                } else {
316                    // If a tool is not specified, it must *not* be a tool identifier.
317                    !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 scripts are present, check that the experimental feature is enabled.
333        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        // Check that setup scripts are named as expected.
346        let (valid_scripts, invalid_scripts): (BTreeSet<_>, _) =
347            this_config.scripts.keys().cloned().partition(|script| {
348                if let Some(tool) = tool {
349                    // The first component must be the tool name.
350                    script
351                        .as_identifier()
352                        .tool_components()
353                        .is_some_and(|(tool_name, _)| tool_name == tool)
354                } else {
355                    // If a tool is not specified, it must *not* be a tool identifier.
356                    !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        // Compile the overrides for this file.
392        let this_compiled = CompiledByProfile::new(pcx, &this_config)
393            .map_err(|kind| ConfigParseError::new(config_file, tool, kind))?;
394
395        // Check that all overrides specify known test groups.
396        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        // Check that override test groups are known.
417        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 there were any unknown groups, error out.
424        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        // Check that scripts are known.
437        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 there were any unknown scripts, error out.
475        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        // Grab the compiled data (default-filter, overrides and setup scripts) for this config,
488        // adding them in reversed order (we'll flip it around at the end).
489        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                    // When inserting a new element, reverse the data.
494                    data.reverse();
495                    entry.insert(data);
496                }
497                hash_map::Entry::Occupied(mut entry) => {
498                    // When appending to an existing element, extend the data in reverse.
499                    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        // The profile was found: construct it.
515        let mut store_dir = self.workspace_root.join(&self.inner.store.dir);
516        store_dir.push(name);
517
518        // Grab the compiled data as well.
519        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    /// This returns a tuple of (config, ignored paths).
536    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                // Both serde_path_to_error and the latest versions of the
551                // config crate report the key. We drop the key from the config
552                // error for consistency.
553                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/// The state of nextest profiles before build platforms have been applied.
569#[derive(Clone, Debug, Default)]
570pub(super) struct PreBuildPlatform {}
571
572/// The state of nextest profiles after build platforms have been applied.
573#[derive(Clone, Debug)]
574pub(crate) struct FinalConfig {
575    // Evaluation result for host_spec on the host platform.
576    pub(super) host_eval: bool,
577    // Evaluation result for target_spec corresponding to tests that run on the host platform (e.g.
578    // proc-macro tests).
579    pub(super) host_test_eval: bool,
580    // Evaluation result for target_spec corresponding to tests that run on the target platform
581    // (most regular tests).
582    pub(super) target_eval: bool,
583}
584
585/// A nextest profile that can be obtained without identifying the host and
586/// target platforms.
587///
588/// Returned by [`NextestConfig::profile`].
589pub 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    // This is ordered because the scripts are used in the order they're defined.
596    scripts: &'cfg IndexMap<ScriptId, ScriptConfig>,
597    // Invariant: `compiled_data.default_filter` is always present.
598    pub(super) compiled_data: CompiledData<PreBuildPlatform>,
599}
600
601impl<'cfg> EarlyProfile<'cfg> {
602    /// Returns the absolute profile-specific store directory.
603    pub fn store_dir(&self) -> &Utf8Path {
604        &self.store_dir
605    }
606
607    /// Returns the global test group configuration.
608    pub fn test_group_config(&self) -> &'cfg BTreeMap<CustomTestGroup, TestGroupConfig> {
609        self.test_groups
610    }
611
612    /// Applies build platforms to make the profile ready for evaluation.
613    ///
614    /// This is a separate step from parsing the config and reading a profile so that cargo-nextest
615    /// can tell users about configuration parsing errors before building the binary list.
616    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            // Look for the default filter in the first valid override.
624            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                // No overrides matching the default filter were found -- use
630                // the profile's default.
631                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/// A configuration profile for nextest. Contains most configuration used by the nextest runner.
653///
654/// Returned by [`EarlyProfile::apply_build_platforms`].
655#[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    // This is ordered because the scripts are used in the order they're defined.
663    scripts: &'cfg IndexMap<ScriptId, ScriptConfig>,
664    // Invariant: `compiled_data.default_filter` is always present.
665    pub(super) compiled_data: CompiledData<FinalConfig>,
666    // The default filter that's been resolved after considering overrides (i.e.
667    // platforms).
668    resolved_default_filter: CompiledDefaultFilter,
669}
670
671impl<'cfg> EvaluatableProfile<'cfg> {
672    /// Returns the name of the profile.
673    pub fn name(&self) -> &str {
674        &self.name
675    }
676
677    /// Returns the absolute profile-specific store directory.
678    pub fn store_dir(&self) -> &Utf8Path {
679        &self.store_dir
680    }
681
682    /// Returns the context in which to evaluate filtersets.
683    pub fn filterset_ecx(&self) -> EvalContext<'_> {
684        EvalContext {
685            default_filter: &self.default_filter().expr,
686        }
687    }
688
689    /// Returns the default set of tests to run.
690    pub fn default_filter(&self) -> &CompiledDefaultFilter {
691        &self.resolved_default_filter
692    }
693
694    /// Returns the global test group configuration.
695    pub fn test_group_config(&self) -> &'cfg BTreeMap<CustomTestGroup, TestGroupConfig> {
696        self.test_groups
697    }
698
699    /// Returns the global script configuration.
700    pub fn script_config(&self) -> &'cfg IndexMap<ScriptId, ScriptConfig> {
701        self.scripts
702    }
703
704    /// Returns the retry count for this profile.
705    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    /// Returns the number of threads to run against for this profile.
712    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    /// Returns the number of threads required for each test.
719    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    /// Returns extra arguments to be passed to the test binary at runtime.
726    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    /// Returns the time after which tests are treated as slow for this profile.
733    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    /// Returns the time after which a child process that hasn't closed its handles is marked as
740    /// leaky.
741    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    /// Returns the test status level.
748    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    /// Returns the test status level at the end of the run.
755    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    /// Returns the failure output config for this profile.
762    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    /// Returns the failure output config for this profile.
769    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    /// Returns the max-fail config for this profile.
776    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    /// Returns the archive configuration for this profile.
783    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    /// Returns the list of setup scripts.
790    pub fn setup_scripts(&self, test_list: &TestList<'_>) -> SetupScripts<'_> {
791        SetupScripts::new(self, test_list)
792    }
793
794    /// Returns settings for individual tests.
795    pub fn settings_for(&self, query: &TestQuery<'_>) -> TestSettings {
796        TestSettings::new(self, query)
797    }
798
799    /// Returns override settings for individual tests, with sources attached.
800    pub(crate) fn settings_with_source_for(
801        &self,
802        query: &TestQuery<'_>,
803    ) -> TestSettings<SettingSource<'_>> {
804        TestSettings::new(self, query)
805    }
806
807    /// Returns the JUnit configuration for this profile.
808    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// This is the form of `NextestConfig` that gets deserialized.
863#[derive(Clone, Debug, Deserialize)]
864#[serde(rename_all = "kebab-case")]
865struct NextestConfigDeserialize {
866    store: StoreConfigImpl,
867
868    // These are parsed as part of NextestConfigVersionOnly. They're re-parsed here to avoid
869    // printing an "unknown key" message.
870    #[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    /// The default set of tests run by `cargo nextest run`.
988    #[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}