nextest_runner/config/core/
imp.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use super::{ExperimentalDeserialize, NextestVersionDeserialize, ToolConfigFile, ToolName};
5use crate::{
6    config::{
7        core::ConfigExperimental,
8        elements::{
9            ArchiveConfig, BenchConfig, CustomTestGroup, DefaultBenchConfig, DefaultJunitImpl,
10            FlakyResult, GlobalTimeout, Inherits, JunitConfig, JunitImpl, JunitSettings,
11            LeakTimeout, MaxFail, RetryPolicy, SlowTimeout, TestGroup, TestGroupConfig,
12            TestThreads, ThreadsRequired, deserialize_fail_fast, deserialize_leak_timeout,
13            deserialize_retry_policy, deserialize_slow_timeout,
14        },
15        overrides::{
16            CompiledByProfile, CompiledData, CompiledDefaultFilter, DeserializedOverride,
17            ListSettings, SettingSource, TestSettings,
18        },
19        scripts::{
20            DeserializedProfileScriptConfig, ProfileScriptType, ScriptConfig, ScriptId, ScriptInfo,
21            SetupScriptConfig, SetupScripts,
22        },
23    },
24    errors::{
25        ConfigParseError, ConfigParseErrorKind, InheritsError,
26        ProfileListScriptUsesRunFiltersError, ProfileNotFound, ProfileScriptErrors,
27        ProfileUnknownScriptError, ProfileWrongConfigScriptTypeError, UnknownTestGroupError,
28        provided_by_tool,
29    },
30    helpers::plural,
31    list::TestList,
32    platform::BuildPlatforms,
33    reporter::{FinalStatusLevel, StatusLevel, TestOutputDisplay},
34    run_mode::NextestRunMode,
35};
36use camino::{Utf8Path, Utf8PathBuf};
37use config::{
38    Config, ConfigBuilder, ConfigError, File, FileFormat, FileSourceFile, builder::DefaultState,
39};
40use iddqd::IdOrdMap;
41use indexmap::IndexMap;
42use nextest_filtering::{BinaryQuery, EvalContext, Filterset, ParseContext, TestQuery};
43use petgraph::{Directed, Graph, algo::scc::kosaraju_scc, graph::NodeIndex};
44use serde::Deserialize;
45use std::{
46    collections::{BTreeMap, BTreeSet, HashMap, hash_map},
47    sync::LazyLock,
48};
49use tracing::warn;
50
51/// Trait for handling configuration warnings.
52///
53/// This trait allows for different warning handling strategies, such as logging warnings
54/// (the default behavior) or collecting them for testing purposes.
55pub trait ConfigWarnings {
56    /// Handle unknown configuration keys found in a config file.
57    fn unknown_config_keys(
58        &mut self,
59        config_file: &Utf8Path,
60        workspace_root: &Utf8Path,
61        tool: Option<&ToolName>,
62        unknown: &BTreeSet<String>,
63    );
64
65    /// Handle unknown profiles found in the reserved `default-` namespace.
66    fn unknown_reserved_profiles(
67        &mut self,
68        config_file: &Utf8Path,
69        workspace_root: &Utf8Path,
70        tool: Option<&ToolName>,
71        profiles: &[&str],
72    );
73
74    /// Handle deprecated `[script.*]` configuration.
75    fn deprecated_script_config(
76        &mut self,
77        config_file: &Utf8Path,
78        workspace_root: &Utf8Path,
79        tool: Option<&ToolName>,
80    );
81
82    /// Handle warning about empty script sections with neither setup nor
83    /// wrapper scripts.
84    fn empty_script_sections(
85        &mut self,
86        config_file: &Utf8Path,
87        workspace_root: &Utf8Path,
88        tool: Option<&ToolName>,
89        profile_name: &str,
90        empty_count: usize,
91    );
92}
93
94/// Default implementation of ConfigWarnings that logs warnings using the tracing crate.
95pub struct DefaultConfigWarnings;
96
97impl ConfigWarnings for DefaultConfigWarnings {
98    fn unknown_config_keys(
99        &mut self,
100        config_file: &Utf8Path,
101        workspace_root: &Utf8Path,
102        tool: Option<&ToolName>,
103        unknown: &BTreeSet<String>,
104    ) {
105        let mut unknown_str = String::new();
106        if unknown.len() == 1 {
107            // Print this on the same line.
108            unknown_str.push_str("key: ");
109            unknown_str.push_str(unknown.iter().next().unwrap());
110        } else {
111            unknown_str.push_str("keys:\n");
112            for ignored_key in unknown {
113                unknown_str.push('\n');
114                unknown_str.push_str("  - ");
115                unknown_str.push_str(ignored_key);
116            }
117        }
118
119        warn!(
120            "in config file {}{}, ignoring unknown configuration {unknown_str}",
121            config_file
122                .strip_prefix(workspace_root)
123                .unwrap_or(config_file),
124            provided_by_tool(tool),
125        )
126    }
127
128    fn unknown_reserved_profiles(
129        &mut self,
130        config_file: &Utf8Path,
131        workspace_root: &Utf8Path,
132        tool: Option<&ToolName>,
133        profiles: &[&str],
134    ) {
135        warn!(
136            "in config file {}{}, ignoring unknown profiles in the reserved `default-` namespace:",
137            config_file
138                .strip_prefix(workspace_root)
139                .unwrap_or(config_file),
140            provided_by_tool(tool),
141        );
142
143        for profile in profiles {
144            warn!("  {profile}");
145        }
146    }
147
148    fn deprecated_script_config(
149        &mut self,
150        config_file: &Utf8Path,
151        workspace_root: &Utf8Path,
152        tool: Option<&ToolName>,
153    ) {
154        warn!(
155            "in config file {}{}, [script.*] is deprecated and will be removed in a \
156             future version of nextest; use the `scripts.setup` table instead",
157            config_file
158                .strip_prefix(workspace_root)
159                .unwrap_or(config_file),
160            provided_by_tool(tool),
161        );
162    }
163
164    fn empty_script_sections(
165        &mut self,
166        config_file: &Utf8Path,
167        workspace_root: &Utf8Path,
168        tool: Option<&ToolName>,
169        profile_name: &str,
170        empty_count: usize,
171    ) {
172        warn!(
173            "in config file {}{}, [[profile.{}.scripts]] has {} {} \
174             with neither setup nor wrapper scripts",
175            config_file
176                .strip_prefix(workspace_root)
177                .unwrap_or(config_file),
178            provided_by_tool(tool),
179            profile_name,
180            empty_count,
181            plural::sections_str(empty_count),
182        );
183    }
184}
185
186/// Gets the number of available CPUs and caches the value.
187#[inline]
188pub fn get_num_cpus() -> usize {
189    static NUM_CPUS: LazyLock<usize> =
190        LazyLock::new(|| match std::thread::available_parallelism() {
191            Ok(count) => count.into(),
192            Err(err) => {
193                warn!("unable to determine num-cpus ({err}), assuming 1 logical CPU");
194                1
195            }
196        });
197
198    *NUM_CPUS
199}
200
201/// Overall configuration for nextest.
202///
203/// This is the root data structure for nextest configuration. Most runner-specific configuration is
204/// managed through [profiles](EvaluatableProfile), obtained through the [`profile`](Self::profile)
205/// method.
206///
207/// For more about configuration, see [_Configuration_](https://nexte.st/docs/configuration) in the
208/// nextest book.
209#[derive(Clone, Debug)]
210pub struct NextestConfig {
211    workspace_root: Utf8PathBuf,
212    inner: NextestConfigImpl,
213    compiled: CompiledByProfile,
214}
215
216impl NextestConfig {
217    /// The default location of the config within the path: `.config/nextest.toml`, used to read the
218    /// config from the given directory.
219    pub const CONFIG_PATH: &'static str = ".config/nextest.toml";
220
221    /// Contains the default config as a TOML file.
222    ///
223    /// Repository-specific configuration is layered on top of the default config.
224    pub const DEFAULT_CONFIG: &'static str = include_str!("../../../default-config.toml");
225
226    /// Environment configuration uses this prefix, plus a _.
227    pub const ENVIRONMENT_PREFIX: &'static str = "NEXTEST";
228
229    /// The name of the default profile.
230    pub const DEFAULT_PROFILE: &'static str = "default";
231
232    /// The name of the default profile used for miri.
233    pub const DEFAULT_MIRI_PROFILE: &'static str = "default-miri";
234
235    /// A list containing the names of the Nextest defined reserved profile names.
236    pub const DEFAULT_PROFILES: &'static [&'static str] =
237        &[Self::DEFAULT_PROFILE, Self::DEFAULT_MIRI_PROFILE];
238
239    /// Reads the nextest config from the given file, or if not specified from `.config/nextest.toml`
240    /// in the workspace root.
241    ///
242    /// `tool_config_files` are lower priority than `config_file` but higher priority than the
243    /// default config. Files in `tool_config_files` that come earlier are higher priority than those
244    /// that come later.
245    ///
246    /// If no config files are specified and this file doesn't have `.config/nextest.toml`, uses the
247    /// default config options.
248    pub fn from_sources<'a, I>(
249        workspace_root: impl Into<Utf8PathBuf>,
250        pcx: &ParseContext<'_>,
251        config_file: Option<&Utf8Path>,
252        tool_config_files: impl IntoIterator<IntoIter = I>,
253        experimental: &BTreeSet<ConfigExperimental>,
254    ) -> Result<Self, ConfigParseError>
255    where
256        I: Iterator<Item = &'a ToolConfigFile> + DoubleEndedIterator,
257    {
258        Self::from_sources_with_warnings(
259            workspace_root,
260            pcx,
261            config_file,
262            tool_config_files,
263            experimental,
264            &mut DefaultConfigWarnings,
265        )
266    }
267
268    /// Load configuration from the given sources with custom warning handling.
269    pub fn from_sources_with_warnings<'a, I>(
270        workspace_root: impl Into<Utf8PathBuf>,
271        pcx: &ParseContext<'_>,
272        config_file: Option<&Utf8Path>,
273        tool_config_files: impl IntoIterator<IntoIter = I>,
274        experimental: &BTreeSet<ConfigExperimental>,
275        warnings: &mut impl ConfigWarnings,
276    ) -> Result<Self, ConfigParseError>
277    where
278        I: Iterator<Item = &'a ToolConfigFile> + DoubleEndedIterator,
279    {
280        Self::from_sources_impl(
281            workspace_root,
282            pcx,
283            config_file,
284            tool_config_files,
285            experimental,
286            warnings,
287        )
288    }
289
290    // A custom unknown_callback can be passed in while testing.
291    fn from_sources_impl<'a, I>(
292        workspace_root: impl Into<Utf8PathBuf>,
293        pcx: &ParseContext<'_>,
294        config_file: Option<&Utf8Path>,
295        tool_config_files: impl IntoIterator<IntoIter = I>,
296        experimental: &BTreeSet<ConfigExperimental>,
297        warnings: &mut impl ConfigWarnings,
298    ) -> Result<Self, ConfigParseError>
299    where
300        I: Iterator<Item = &'a ToolConfigFile> + DoubleEndedIterator,
301    {
302        let workspace_root = workspace_root.into();
303        let tool_config_files_rev = tool_config_files.into_iter().rev();
304        let (inner, compiled) = Self::read_from_sources(
305            pcx,
306            &workspace_root,
307            config_file,
308            tool_config_files_rev,
309            experimental,
310            warnings,
311        )?;
312        Ok(Self {
313            workspace_root,
314            inner,
315            compiled,
316        })
317    }
318
319    /// Returns the default nextest config.
320    #[cfg(test)]
321    pub(crate) fn default_config(workspace_root: impl Into<Utf8PathBuf>) -> Self {
322        use itertools::Itertools;
323
324        let config = Self::make_default_config()
325            .build()
326            .expect("default config is always valid");
327
328        let mut unknown = BTreeSet::new();
329        let deserialized: NextestConfigDeserialize =
330            serde_ignored::deserialize(config, |path: serde_ignored::Path| {
331                unknown.insert(path.to_string());
332            })
333            .expect("default config is always valid");
334
335        // Make sure there aren't any unknown keys in the default config, since it is
336        // embedded/shipped with this binary.
337        if !unknown.is_empty() {
338            panic!(
339                "found unknown keys in default config: {}",
340                unknown.iter().join(", ")
341            );
342        }
343
344        Self {
345            workspace_root: workspace_root.into(),
346            inner: deserialized.into_config_impl(),
347            // The default config has no overrides or special settings.
348            compiled: CompiledByProfile::for_default_config(),
349        }
350    }
351
352    /// Returns the profile with the given name, or an error if a profile was
353    /// specified but not found.
354    pub fn profile(&self, name: impl AsRef<str>) -> Result<EarlyProfile<'_>, ProfileNotFound> {
355        self.make_profile(name.as_ref())
356    }
357
358    // ---
359    // Helper methods
360    // ---
361
362    fn read_from_sources<'a>(
363        pcx: &ParseContext<'_>,
364        workspace_root: &Utf8Path,
365        file: Option<&Utf8Path>,
366        tool_config_files_rev: impl Iterator<Item = &'a ToolConfigFile>,
367        experimental: &BTreeSet<ConfigExperimental>,
368        warnings: &mut impl ConfigWarnings,
369    ) -> Result<(NextestConfigImpl, CompiledByProfile), ConfigParseError> {
370        // First, get the default config.
371        let mut composite_builder = Self::make_default_config();
372
373        // Overrides are handled additively.
374        // Note that they're stored in reverse order here, and are flipped over at the end.
375        let mut compiled = CompiledByProfile::for_default_config();
376
377        let mut known_groups = BTreeSet::new();
378        let mut known_scripts = IdOrdMap::new();
379        // Track known profiles for inheritance validation. Profiles can only inherit
380        // from profiles defined in the same file or in previously loaded (lower priority) files.
381        let mut known_profiles = BTreeSet::new();
382
383        // Next, merge in tool configs.
384        for ToolConfigFile { config_file, tool } in tool_config_files_rev {
385            let source = File::new(config_file.as_str(), FileFormat::Toml);
386            Self::deserialize_individual_config(
387                pcx,
388                workspace_root,
389                config_file,
390                Some(tool),
391                source.clone(),
392                &mut compiled,
393                experimental,
394                warnings,
395                &mut known_groups,
396                &mut known_scripts,
397                &mut known_profiles,
398            )?;
399
400            // This is the final, composite builder used at the end.
401            composite_builder = composite_builder.add_source(source);
402        }
403
404        // Next, merge in the config from the given file.
405        let (config_file, source) = match file {
406            Some(file) => (file.to_owned(), File::new(file.as_str(), FileFormat::Toml)),
407            None => {
408                let config_file = workspace_root.join(Self::CONFIG_PATH);
409                let source = File::new(config_file.as_str(), FileFormat::Toml).required(false);
410                (config_file, source)
411            }
412        };
413
414        Self::deserialize_individual_config(
415            pcx,
416            workspace_root,
417            &config_file,
418            None,
419            source.clone(),
420            &mut compiled,
421            experimental,
422            warnings,
423            &mut known_groups,
424            &mut known_scripts,
425            &mut known_profiles,
426        )?;
427
428        composite_builder = composite_builder.add_source(source);
429
430        // The unknown set is ignored here because any values in it have already been reported in
431        // deserialize_individual_config.
432        let (config, _unknown) = Self::build_and_deserialize_config(&composite_builder)
433            .map_err(|kind| ConfigParseError::new(&config_file, None, kind))?;
434
435        // Reverse all the compiled data at the end.
436        compiled.default.reverse();
437        for data in compiled.other.values_mut() {
438            data.reverse();
439        }
440
441        Ok((config.into_config_impl(), compiled))
442    }
443
444    #[expect(clippy::too_many_arguments)]
445    fn deserialize_individual_config(
446        pcx: &ParseContext<'_>,
447        workspace_root: &Utf8Path,
448        config_file: &Utf8Path,
449        tool: Option<&ToolName>,
450        source: File<FileSourceFile, FileFormat>,
451        compiled_out: &mut CompiledByProfile,
452        experimental: &BTreeSet<ConfigExperimental>,
453        warnings: &mut impl ConfigWarnings,
454        known_groups: &mut BTreeSet<CustomTestGroup>,
455        known_scripts: &mut IdOrdMap<ScriptInfo>,
456        known_profiles: &mut BTreeSet<String>,
457    ) -> Result<(), ConfigParseError> {
458        // Try building default builder + this file to get good error attribution and handle
459        // overrides additively.
460        let default_builder = Self::make_default_config();
461        let this_builder = default_builder.add_source(source);
462        let (mut this_config, unknown) = Self::build_and_deserialize_config(&this_builder)
463            .map_err(|kind| ConfigParseError::new(config_file, tool, kind))?;
464
465        if !unknown.is_empty() {
466            warnings.unknown_config_keys(config_file, workspace_root, tool, &unknown);
467        }
468
469        // Check that test groups are named as expected.
470        let (valid_groups, invalid_groups): (BTreeSet<_>, _) =
471            this_config.test_groups.keys().cloned().partition(|group| {
472                if let Some(tool) = tool {
473                    // The first component must be the tool name.
474                    group
475                        .as_identifier()
476                        .tool_components()
477                        .is_some_and(|(tool_name, _)| tool_name == tool.as_str())
478                } else {
479                    // If a tool is not specified, it must *not* be a tool identifier.
480                    !group.as_identifier().is_tool_identifier()
481                }
482            });
483
484        if !invalid_groups.is_empty() {
485            let kind = if tool.is_some() {
486                ConfigParseErrorKind::InvalidTestGroupsDefinedByTool(invalid_groups)
487            } else {
488                ConfigParseErrorKind::InvalidTestGroupsDefined(invalid_groups)
489            };
490            return Err(ConfigParseError::new(config_file, tool, kind));
491        }
492
493        known_groups.extend(valid_groups);
494
495        // If both scripts and old_setup_scripts are present, produce an error.
496        if !this_config.scripts.is_empty() && !this_config.old_setup_scripts.is_empty() {
497            return Err(ConfigParseError::new(
498                config_file,
499                tool,
500                ConfigParseErrorKind::BothScriptAndScriptsDefined,
501            ));
502        }
503
504        // If old_setup_scripts are present, produce a warning.
505        if !this_config.old_setup_scripts.is_empty() {
506            warnings.deprecated_script_config(config_file, workspace_root, tool);
507            this_config.scripts.setup = this_config.old_setup_scripts.clone();
508        }
509
510        // Check for experimental features that are used but not enabled.
511        {
512            let mut missing_features = BTreeSet::new();
513            if !this_config.scripts.setup.is_empty()
514                && !experimental.contains(&ConfigExperimental::SetupScripts)
515            {
516                missing_features.insert(ConfigExperimental::SetupScripts);
517            }
518            if !this_config.scripts.wrapper.is_empty()
519                && !experimental.contains(&ConfigExperimental::WrapperScripts)
520            {
521                missing_features.insert(ConfigExperimental::WrapperScripts);
522            }
523            if !missing_features.is_empty() {
524                return Err(ConfigParseError::new(
525                    config_file,
526                    tool,
527                    ConfigParseErrorKind::ExperimentalFeaturesNotEnabled { missing_features },
528                ));
529            }
530        }
531
532        let duplicate_ids: BTreeSet<_> = this_config.scripts.duplicate_ids().cloned().collect();
533        if !duplicate_ids.is_empty() {
534            return Err(ConfigParseError::new(
535                config_file,
536                tool,
537                ConfigParseErrorKind::DuplicateConfigScriptNames(duplicate_ids),
538            ));
539        }
540
541        // Check that setup scripts are named as expected.
542        let (valid_scripts, invalid_scripts): (BTreeSet<_>, _) = this_config
543            .scripts
544            .all_script_ids()
545            .cloned()
546            .partition(|script| {
547                if let Some(tool) = tool {
548                    // The first component must be the tool name.
549                    script
550                        .as_identifier()
551                        .tool_components()
552                        .is_some_and(|(tool_name, _)| tool_name == tool.as_str())
553                } else {
554                    // If a tool is not specified, it must *not* be a tool identifier.
555                    !script.as_identifier().is_tool_identifier()
556                }
557            });
558
559        if !invalid_scripts.is_empty() {
560            let kind = if tool.is_some() {
561                ConfigParseErrorKind::InvalidConfigScriptsDefinedByTool(invalid_scripts)
562            } else {
563                ConfigParseErrorKind::InvalidConfigScriptsDefined(invalid_scripts)
564            };
565            return Err(ConfigParseError::new(config_file, tool, kind));
566        }
567
568        known_scripts.extend(
569            valid_scripts
570                .into_iter()
571                .map(|id| this_config.scripts.script_info(id)),
572        );
573
574        let this_config = this_config.into_config_impl();
575
576        let unknown_default_profiles: Vec<_> = this_config
577            .all_profiles()
578            .filter(|p| p.starts_with("default-") && !NextestConfig::DEFAULT_PROFILES.contains(p))
579            .collect();
580        if !unknown_default_profiles.is_empty() {
581            warnings.unknown_reserved_profiles(
582                config_file,
583                workspace_root,
584                tool,
585                &unknown_default_profiles,
586            );
587        }
588
589        // Check that the profiles correctly use the inherits setting.
590        // Profiles can only inherit from profiles in the same file or in previously
591        // loaded (lower priority) files.
592        this_config
593            .sanitize_profile_inherits(known_profiles)
594            .map_err(|kind| ConfigParseError::new(config_file, tool, kind))?;
595
596        // Add this file's profiles to known_profiles for subsequent files.
597        known_profiles.extend(
598            this_config
599                .other_profiles()
600                .map(|(name, _)| name.to_owned()),
601        );
602
603        // Compile the overrides for this file.
604        let this_compiled = CompiledByProfile::new(pcx, &this_config)
605            .map_err(|kind| ConfigParseError::new(config_file, tool, kind))?;
606
607        // Check that all overrides specify known test groups.
608        let mut unknown_group_errors = Vec::new();
609        let mut check_test_group = |profile_name: &str, test_group: Option<&TestGroup>| {
610            if let Some(TestGroup::Custom(group)) = test_group
611                && !known_groups.contains(group)
612            {
613                unknown_group_errors.push(UnknownTestGroupError {
614                    profile_name: profile_name.to_owned(),
615                    name: TestGroup::Custom(group.clone()),
616                });
617            }
618        };
619
620        this_compiled
621            .default
622            .overrides
623            .iter()
624            .for_each(|override_| {
625                check_test_group("default", override_.data.test_group.as_ref());
626            });
627
628        // Check that override test groups are known.
629        this_compiled.other.iter().for_each(|(profile_name, data)| {
630            data.overrides.iter().for_each(|override_| {
631                check_test_group(profile_name, override_.data.test_group.as_ref());
632            });
633        });
634
635        // If there were any unknown groups, error out.
636        if !unknown_group_errors.is_empty() {
637            let known_groups = TestGroup::make_all_groups(known_groups.iter().cloned()).collect();
638            return Err(ConfigParseError::new(
639                config_file,
640                tool,
641                ConfigParseErrorKind::UnknownTestGroups {
642                    errors: unknown_group_errors,
643                    known_groups,
644                },
645            ));
646        }
647
648        // Check that scripts are known and that there aren't any other errors
649        // with them.
650        let mut profile_script_errors = ProfileScriptErrors::default();
651        let mut check_script_ids = |profile_name: &str,
652                                    script_type: ProfileScriptType,
653                                    expr: Option<&Filterset>,
654                                    scripts: &[ScriptId]| {
655            for script in scripts {
656                if let Some(script_info) = known_scripts.get(script) {
657                    if !script_info.script_type.matches(script_type) {
658                        profile_script_errors.wrong_script_types.push(
659                            ProfileWrongConfigScriptTypeError {
660                                profile_name: profile_name.to_owned(),
661                                name: script.clone(),
662                                attempted: script_type,
663                                actual: script_info.script_type,
664                            },
665                        );
666                    }
667                    if script_type == ProfileScriptType::ListWrapper
668                        && let Some(expr) = expr
669                    {
670                        let runtime_only_leaves = expr.parsed.runtime_only_leaves();
671                        if !runtime_only_leaves.is_empty() {
672                            let filters = runtime_only_leaves
673                                .iter()
674                                .map(|leaf| leaf.to_string())
675                                .collect();
676                            profile_script_errors.list_scripts_using_run_filters.push(
677                                ProfileListScriptUsesRunFiltersError {
678                                    profile_name: profile_name.to_owned(),
679                                    name: script.clone(),
680                                    script_type,
681                                    filters,
682                                },
683                            );
684                        }
685                    }
686                } else {
687                    profile_script_errors
688                        .unknown_scripts
689                        .push(ProfileUnknownScriptError {
690                            profile_name: profile_name.to_owned(),
691                            name: script.clone(),
692                        });
693                }
694            }
695        };
696
697        let mut empty_script_count = 0;
698
699        this_compiled.default.scripts.iter().for_each(|scripts| {
700            if scripts.setup.is_empty()
701                && scripts.list_wrapper.is_none()
702                && scripts.run_wrapper.is_none()
703            {
704                empty_script_count += 1;
705            }
706
707            check_script_ids(
708                "default",
709                ProfileScriptType::Setup,
710                scripts.data.expr(),
711                &scripts.setup,
712            );
713            check_script_ids(
714                "default",
715                ProfileScriptType::ListWrapper,
716                scripts.data.expr(),
717                scripts.list_wrapper.as_slice(),
718            );
719            check_script_ids(
720                "default",
721                ProfileScriptType::RunWrapper,
722                scripts.data.expr(),
723                scripts.run_wrapper.as_slice(),
724            );
725        });
726
727        if empty_script_count > 0 {
728            warnings.empty_script_sections(
729                config_file,
730                workspace_root,
731                tool,
732                "default",
733                empty_script_count,
734            );
735        }
736
737        this_compiled.other.iter().for_each(|(profile_name, data)| {
738            let mut empty_script_count = 0;
739            data.scripts.iter().for_each(|scripts| {
740                if scripts.setup.is_empty()
741                    && scripts.list_wrapper.is_none()
742                    && scripts.run_wrapper.is_none()
743                {
744                    empty_script_count += 1;
745                }
746
747                check_script_ids(
748                    profile_name,
749                    ProfileScriptType::Setup,
750                    scripts.data.expr(),
751                    &scripts.setup,
752                );
753                check_script_ids(
754                    profile_name,
755                    ProfileScriptType::ListWrapper,
756                    scripts.data.expr(),
757                    scripts.list_wrapper.as_slice(),
758                );
759                check_script_ids(
760                    profile_name,
761                    ProfileScriptType::RunWrapper,
762                    scripts.data.expr(),
763                    scripts.run_wrapper.as_slice(),
764                );
765            });
766
767            if empty_script_count > 0 {
768                warnings.empty_script_sections(
769                    config_file,
770                    workspace_root,
771                    tool,
772                    profile_name,
773                    empty_script_count,
774                );
775            }
776        });
777
778        // If there were any errors parsing profile-specific script data, error
779        // out.
780        if !profile_script_errors.is_empty() {
781            let known_scripts = known_scripts
782                .iter()
783                .map(|script| script.id.clone())
784                .collect();
785            return Err(ConfigParseError::new(
786                config_file,
787                tool,
788                ConfigParseErrorKind::ProfileScriptErrors {
789                    errors: Box::new(profile_script_errors),
790                    known_scripts,
791                },
792            ));
793        }
794
795        // Grab the compiled data (default-filter, overrides and setup scripts) for this config,
796        // adding them in reversed order (we'll flip it around at the end).
797        compiled_out.default.extend_reverse(this_compiled.default);
798        for (name, mut data) in this_compiled.other {
799            match compiled_out.other.entry(name) {
800                hash_map::Entry::Vacant(entry) => {
801                    // When inserting a new element, reverse the data.
802                    data.reverse();
803                    entry.insert(data);
804                }
805                hash_map::Entry::Occupied(mut entry) => {
806                    // When appending to an existing element, extend the data in reverse.
807                    entry.get_mut().extend_reverse(data);
808                }
809            }
810        }
811
812        Ok(())
813    }
814
815    fn make_default_config() -> ConfigBuilder<DefaultState> {
816        Config::builder().add_source(File::from_str(Self::DEFAULT_CONFIG, FileFormat::Toml))
817    }
818
819    fn make_profile(&self, name: &str) -> Result<EarlyProfile<'_>, ProfileNotFound> {
820        let custom_profile = self.inner.get_profile(name)?;
821
822        // Resolve the inherited profile into a profile chain
823        let inheritance_chain = self.inner.resolve_inheritance_chain(name)?;
824
825        // The profile was found: construct it.
826        let mut store_dir = self.workspace_root.join(&self.inner.store.dir);
827        store_dir.push(name);
828
829        // Grab the compiled data as well.
830        let compiled_data = match self.compiled.other.get(name) {
831            Some(data) => data.clone().chain(self.compiled.default.clone()),
832            None => self.compiled.default.clone(),
833        };
834
835        Ok(EarlyProfile {
836            name: name.to_owned(),
837            store_dir,
838            default_profile: &self.inner.default_profile,
839            custom_profile,
840            inheritance_chain,
841            test_groups: &self.inner.test_groups,
842            scripts: &self.inner.scripts,
843            compiled_data,
844        })
845    }
846
847    /// This returns a tuple of (config, ignored paths).
848    fn build_and_deserialize_config(
849        builder: &ConfigBuilder<DefaultState>,
850    ) -> Result<(NextestConfigDeserialize, BTreeSet<String>), ConfigParseErrorKind> {
851        let config = builder
852            .build_cloned()
853            .map_err(|error| ConfigParseErrorKind::BuildError(Box::new(error)))?;
854
855        let mut ignored = BTreeSet::new();
856        let mut cb = |path: serde_ignored::Path| {
857            ignored.insert(path.to_string());
858        };
859        let ignored_de = serde_ignored::Deserializer::new(config, &mut cb);
860        let config: NextestConfigDeserialize = serde_path_to_error::deserialize(ignored_de)
861            .map_err(|error| {
862                // Both serde_path_to_error and the latest versions of the
863                // config crate report the key. We drop the key from the config
864                // error for consistency.
865                let path = error.path().clone();
866                let config_error = error.into_inner();
867                let error = match config_error {
868                    ConfigError::At { error, .. } => *error,
869                    other => other,
870                };
871                ConfigParseErrorKind::DeserializeError(Box::new(serde_path_to_error::Error::new(
872                    path, error,
873                )))
874            })?;
875
876        Ok((config, ignored))
877    }
878}
879
880/// The state of nextest profiles before build platforms have been applied.
881#[derive(Clone, Debug, Default)]
882pub(in crate::config) struct PreBuildPlatform {}
883
884/// The state of nextest profiles after build platforms have been applied.
885#[derive(Clone, Debug)]
886pub(crate) struct FinalConfig {
887    // Evaluation result for host_spec on the host platform.
888    pub(in crate::config) host_eval: bool,
889    // Evaluation result for target_spec corresponding to tests that run on the host platform (e.g.
890    // proc-macro tests).
891    pub(in crate::config) host_test_eval: bool,
892    // Evaluation result for target_spec corresponding to tests that run on the target platform
893    // (most regular tests).
894    pub(in crate::config) target_eval: bool,
895}
896
897/// A nextest profile that can be obtained without identifying the host and
898/// target platforms.
899///
900/// Returned by [`NextestConfig::profile`].
901pub struct EarlyProfile<'cfg> {
902    name: String,
903    store_dir: Utf8PathBuf,
904    default_profile: &'cfg DefaultProfileImpl,
905    custom_profile: Option<&'cfg CustomProfileImpl>,
906    inheritance_chain: Vec<&'cfg CustomProfileImpl>,
907    test_groups: &'cfg BTreeMap<CustomTestGroup, TestGroupConfig>,
908    // This is ordered because the scripts are used in the order they're defined.
909    scripts: &'cfg ScriptConfig,
910    // Invariant: `compiled_data.default_filter` is always present.
911    pub(in crate::config) compiled_data: CompiledData<PreBuildPlatform>,
912}
913
914/// These macros return a specific config field from a profile, checking in
915/// order: custom profile, inheritance chain, then default profile.
916macro_rules! profile_field {
917    ($eval_prof:ident.$field:ident) => {
918        $eval_prof
919            .custom_profile
920            .iter()
921            .chain($eval_prof.inheritance_chain.iter())
922            .find_map(|p| p.$field)
923            .unwrap_or($eval_prof.default_profile.$field)
924    };
925    ($eval_prof:ident.$nested:ident.$field:ident) => {
926        $eval_prof
927            .custom_profile
928            .iter()
929            .chain($eval_prof.inheritance_chain.iter())
930            .find_map(|p| p.$nested.$field)
931            .unwrap_or($eval_prof.default_profile.$nested.$field)
932    };
933    // Variant for method calls with arguments.
934    ($eval_prof:ident.$method:ident($($arg:expr),*)) => {
935        $eval_prof
936            .custom_profile
937            .iter()
938            .chain($eval_prof.inheritance_chain.iter())
939            .find_map(|p| p.$method($($arg),*))
940            .unwrap_or_else(|| $eval_prof.default_profile.$method($($arg),*))
941    };
942}
943macro_rules! profile_field_from_ref {
944    ($eval_prof:ident.$field:ident.$ref_func:ident()) => {
945        $eval_prof
946            .custom_profile
947            .iter()
948            .chain($eval_prof.inheritance_chain.iter())
949            .find_map(|p| p.$field.$ref_func())
950            .unwrap_or(&$eval_prof.default_profile.$field)
951    };
952    ($eval_prof:ident.$nested:ident.$field:ident.$ref_func:ident()) => {
953        $eval_prof
954            .custom_profile
955            .iter()
956            .chain($eval_prof.inheritance_chain.iter())
957            .find_map(|p| p.$nested.$field.$ref_func())
958            .unwrap_or(&$eval_prof.default_profile.$nested.$field)
959    };
960}
961// Variant for fields where both custom and default are Option.
962macro_rules! profile_field_optional {
963    ($eval_prof:ident.$nested:ident.$field:ident.$ref_func:ident()) => {
964        $eval_prof
965            .custom_profile
966            .iter()
967            .chain($eval_prof.inheritance_chain.iter())
968            .find_map(|p| p.$nested.$field.$ref_func())
969            .or($eval_prof.default_profile.$nested.$field.$ref_func())
970    };
971}
972
973impl<'cfg> EarlyProfile<'cfg> {
974    /// Returns the absolute profile-specific store directory.
975    pub fn store_dir(&self) -> &Utf8Path {
976        &self.store_dir
977    }
978
979    /// Returns true if JUnit XML output is configured for this profile.
980    pub fn has_junit(&self) -> bool {
981        profile_field_optional!(self.junit.path.as_deref()).is_some()
982    }
983
984    /// Returns the global test group configuration.
985    pub fn test_group_config(&self) -> &'cfg BTreeMap<CustomTestGroup, TestGroupConfig> {
986        self.test_groups
987    }
988
989    /// Applies build platforms to make the profile ready for evaluation.
990    ///
991    /// This is a separate step from parsing the config and reading a profile so that cargo-nextest
992    /// can tell users about configuration parsing errors before building the binary list.
993    pub fn apply_build_platforms(
994        self,
995        build_platforms: &BuildPlatforms,
996    ) -> EvaluatableProfile<'cfg> {
997        let compiled_data = self.compiled_data.apply_build_platforms(build_platforms);
998
999        let resolved_default_filter = {
1000            // Look for the default filter in the first valid override.
1001            let found_filter = compiled_data
1002                .overrides
1003                .iter()
1004                .find_map(|override_data| override_data.default_filter_if_matches_platform());
1005            found_filter.unwrap_or_else(|| {
1006                // No overrides matching the default filter were found -- use
1007                // the profile's default.
1008                compiled_data
1009                    .profile_default_filter
1010                    .as_ref()
1011                    .expect("compiled data always has default set")
1012            })
1013        }
1014        .clone();
1015
1016        EvaluatableProfile {
1017            name: self.name,
1018            store_dir: self.store_dir,
1019            default_profile: self.default_profile,
1020            custom_profile: self.custom_profile,
1021            inheritance_chain: self.inheritance_chain,
1022            scripts: self.scripts,
1023            test_groups: self.test_groups,
1024            compiled_data,
1025            resolved_default_filter,
1026        }
1027    }
1028}
1029
1030/// A configuration profile for nextest. Contains most configuration used by the nextest runner.
1031///
1032/// Returned by [`EarlyProfile::apply_build_platforms`].
1033#[derive(Clone, Debug)]
1034pub struct EvaluatableProfile<'cfg> {
1035    name: String,
1036    store_dir: Utf8PathBuf,
1037    default_profile: &'cfg DefaultProfileImpl,
1038    custom_profile: Option<&'cfg CustomProfileImpl>,
1039    inheritance_chain: Vec<&'cfg CustomProfileImpl>,
1040    test_groups: &'cfg BTreeMap<CustomTestGroup, TestGroupConfig>,
1041    // This is ordered because the scripts are used in the order they're defined.
1042    scripts: &'cfg ScriptConfig,
1043    // Invariant: `compiled_data.default_filter` is always present.
1044    pub(in crate::config) compiled_data: CompiledData<FinalConfig>,
1045    // The default filter that's been resolved after considering overrides (i.e.
1046    // platforms).
1047    resolved_default_filter: CompiledDefaultFilter,
1048}
1049
1050impl<'cfg> EvaluatableProfile<'cfg> {
1051    /// Returns the name of the profile.
1052    pub fn name(&self) -> &str {
1053        &self.name
1054    }
1055
1056    /// Returns the absolute profile-specific store directory.
1057    pub fn store_dir(&self) -> &Utf8Path {
1058        &self.store_dir
1059    }
1060
1061    /// Returns the context in which to evaluate filtersets.
1062    pub fn filterset_ecx(&self) -> EvalContext<'_> {
1063        EvalContext {
1064            default_filter: &self.default_filter().expr,
1065        }
1066    }
1067
1068    /// Returns the default set of tests to run.
1069    pub fn default_filter(&self) -> &CompiledDefaultFilter {
1070        &self.resolved_default_filter
1071    }
1072
1073    /// Returns the global test group configuration.
1074    pub fn test_group_config(&self) -> &'cfg BTreeMap<CustomTestGroup, TestGroupConfig> {
1075        self.test_groups
1076    }
1077
1078    /// Returns the global script configuration.
1079    pub fn script_config(&self) -> &'cfg ScriptConfig {
1080        self.scripts
1081    }
1082
1083    /// Returns the retry policy for this profile.
1084    pub fn retries(&self) -> RetryPolicy {
1085        profile_field!(self.retries)
1086    }
1087
1088    /// Returns the flaky result behavior for this profile.
1089    pub fn flaky_result(&self) -> FlakyResult {
1090        profile_field!(self.flaky_result)
1091    }
1092
1093    /// Returns the number of threads to run against for this profile.
1094    pub fn test_threads(&self) -> TestThreads {
1095        profile_field!(self.test_threads)
1096    }
1097
1098    /// Returns the number of threads required for each test.
1099    pub fn threads_required(&self) -> ThreadsRequired {
1100        profile_field!(self.threads_required)
1101    }
1102
1103    /// Returns extra arguments to be passed to the test binary at runtime.
1104    pub fn run_extra_args(&self) -> &'cfg [String] {
1105        profile_field_from_ref!(self.run_extra_args.as_deref())
1106    }
1107
1108    /// Returns the time after which tests are treated as slow for this profile.
1109    pub fn slow_timeout(&self, run_mode: NextestRunMode) -> SlowTimeout {
1110        profile_field!(self.slow_timeout(run_mode))
1111    }
1112
1113    /// Returns the time after which we should stop running tests.
1114    pub fn global_timeout(&self, run_mode: NextestRunMode) -> GlobalTimeout {
1115        profile_field!(self.global_timeout(run_mode))
1116    }
1117
1118    /// Returns the time after which a child process that hasn't closed its handles is marked as
1119    /// leaky.
1120    pub fn leak_timeout(&self) -> LeakTimeout {
1121        profile_field!(self.leak_timeout)
1122    }
1123
1124    /// Returns the test status level.
1125    pub fn status_level(&self) -> StatusLevel {
1126        profile_field!(self.status_level)
1127    }
1128
1129    /// Returns the test status level at the end of the run.
1130    pub fn final_status_level(&self) -> FinalStatusLevel {
1131        profile_field!(self.final_status_level)
1132    }
1133
1134    /// Returns the failure output config for this profile.
1135    pub fn failure_output(&self) -> TestOutputDisplay {
1136        profile_field!(self.failure_output)
1137    }
1138
1139    /// Returns the failure output config for this profile.
1140    pub fn success_output(&self) -> TestOutputDisplay {
1141        profile_field!(self.success_output)
1142    }
1143
1144    /// Returns the max-fail config for this profile.
1145    pub fn max_fail(&self) -> MaxFail {
1146        profile_field!(self.max_fail)
1147    }
1148
1149    /// Returns the archive configuration for this profile.
1150    pub fn archive_config(&self) -> &'cfg ArchiveConfig {
1151        profile_field_from_ref!(self.archive.as_ref())
1152    }
1153
1154    /// Returns the list of setup scripts.
1155    pub fn setup_scripts(&self, test_list: &TestList<'_>) -> SetupScripts<'_> {
1156        SetupScripts::new(self, test_list)
1157    }
1158
1159    /// Returns list-time settings for a test binary.
1160    pub fn list_settings_for(&self, query: &BinaryQuery<'_>) -> ListSettings<'_> {
1161        ListSettings::new(self, query)
1162    }
1163
1164    /// Returns settings for individual tests.
1165    pub fn settings_for(
1166        &self,
1167        run_mode: NextestRunMode,
1168        query: &TestQuery<'_>,
1169    ) -> TestSettings<'_> {
1170        TestSettings::new(self, run_mode, query)
1171    }
1172
1173    /// Returns override settings for individual tests, with sources attached.
1174    pub(crate) fn settings_with_source_for(
1175        &self,
1176        run_mode: NextestRunMode,
1177        query: &TestQuery<'_>,
1178    ) -> TestSettings<'_, SettingSource<'_>> {
1179        TestSettings::new(self, run_mode, query)
1180    }
1181
1182    /// Returns the JUnit configuration for this profile.
1183    pub fn junit(&self) -> Option<JunitConfig<'cfg>> {
1184        let settings = JunitSettings {
1185            path: profile_field_optional!(self.junit.path.as_deref()),
1186            report_name: profile_field_from_ref!(self.junit.report_name.as_deref()),
1187            store_success_output: profile_field!(self.junit.store_success_output),
1188            store_failure_output: profile_field!(self.junit.store_failure_output),
1189            flaky_fail_status: profile_field!(self.junit.flaky_fail_status),
1190        };
1191        JunitConfig::new(self.store_dir(), settings)
1192    }
1193
1194    /// Returns the profile that this profile inherits from.
1195    pub fn inherits(&self) -> Option<&str> {
1196        if let Some(custom_profile) = self.custom_profile {
1197            return custom_profile.inherits();
1198        }
1199        None
1200    }
1201
1202    #[cfg(test)]
1203    pub(in crate::config) fn custom_profile(&self) -> Option<&'cfg CustomProfileImpl> {
1204        self.custom_profile
1205    }
1206}
1207
1208#[derive(Clone, Debug)]
1209pub(in crate::config) struct NextestConfigImpl {
1210    store: StoreConfigImpl,
1211    test_groups: BTreeMap<CustomTestGroup, TestGroupConfig>,
1212    scripts: ScriptConfig,
1213    default_profile: DefaultProfileImpl,
1214    other_profiles: HashMap<String, CustomProfileImpl>,
1215}
1216
1217impl NextestConfigImpl {
1218    fn get_profile(&self, profile: &str) -> Result<Option<&CustomProfileImpl>, ProfileNotFound> {
1219        let custom_profile = match profile {
1220            NextestConfig::DEFAULT_PROFILE => None,
1221            other => Some(
1222                self.other_profiles
1223                    .get(other)
1224                    .ok_or_else(|| ProfileNotFound::new(profile, self.all_profiles()))?,
1225            ),
1226        };
1227        Ok(custom_profile)
1228    }
1229
1230    fn all_profiles(&self) -> impl Iterator<Item = &str> {
1231        self.other_profiles
1232            .keys()
1233            .map(|key| key.as_str())
1234            .chain(std::iter::once(NextestConfig::DEFAULT_PROFILE))
1235    }
1236
1237    pub(in crate::config) fn default_profile(&self) -> &DefaultProfileImpl {
1238        &self.default_profile
1239    }
1240
1241    pub(in crate::config) fn other_profiles(
1242        &self,
1243    ) -> impl Iterator<Item = (&str, &CustomProfileImpl)> {
1244        self.other_profiles
1245            .iter()
1246            .map(|(key, value)| (key.as_str(), value))
1247    }
1248
1249    /// Resolve a profile's inheritance chain (ancestors only, not including the
1250    /// profile itself).
1251    ///
1252    /// Returns the chain ordered from immediate parent to furthest ancestor.
1253    /// Cycles are assumed to have been checked by `sanitize_profile_inherits()`.
1254    fn resolve_inheritance_chain(
1255        &self,
1256        profile_name: &str,
1257    ) -> Result<Vec<&CustomProfileImpl>, ProfileNotFound> {
1258        let mut chain = Vec::new();
1259
1260        // Start from the profile's parent, not the profile itself (the profile
1261        // is already available via custom_profile).
1262        let mut curr = self
1263            .get_profile(profile_name)?
1264            .and_then(|p| p.inherits.as_deref());
1265
1266        while let Some(name) = curr {
1267            let profile = self.get_profile(name)?;
1268            if let Some(profile) = profile {
1269                chain.push(profile);
1270                curr = profile.inherits.as_deref();
1271            } else {
1272                // Reached the default profile -- stop.
1273                break;
1274            }
1275        }
1276
1277        Ok(chain)
1278    }
1279
1280    /// Sanitize inherits settings on default and custom profiles.
1281    ///
1282    /// `known_profiles` contains profiles from previously loaded (lower priority) files.
1283    /// A profile can inherit from profiles in the same file or in `known_profiles`.
1284    fn sanitize_profile_inherits(
1285        &self,
1286        known_profiles: &BTreeSet<String>,
1287    ) -> Result<(), ConfigParseErrorKind> {
1288        let mut inherit_err_collector = Vec::new();
1289
1290        self.sanitize_default_profile_inherits(&mut inherit_err_collector);
1291        self.sanitize_custom_profile_inherits(&mut inherit_err_collector, known_profiles);
1292
1293        if !inherit_err_collector.is_empty() {
1294            return Err(ConfigParseErrorKind::InheritanceErrors(
1295                inherit_err_collector,
1296            ));
1297        }
1298
1299        Ok(())
1300    }
1301
1302    /// Check the DefaultProfileImpl and make sure that it doesn't inherit from other
1303    /// profiles
1304    fn sanitize_default_profile_inherits(&self, inherit_err_collector: &mut Vec<InheritsError>) {
1305        if self.default_profile().inherits().is_some() {
1306            inherit_err_collector.push(InheritsError::DefaultProfileInheritance(
1307                NextestConfig::DEFAULT_PROFILE.to_string(),
1308            ));
1309        }
1310    }
1311
1312    /// Iterate through each custom profile inherits and report any inheritance error(s).
1313    fn sanitize_custom_profile_inherits(
1314        &self,
1315        inherit_err_collector: &mut Vec<InheritsError>,
1316        known_profiles: &BTreeSet<String>,
1317    ) {
1318        let mut profile_graph = Graph::<&str, (), Directed>::new();
1319        let mut profile_map = HashMap::new();
1320
1321        // Iterate through all custom profiles within the config file and constructs
1322        // a reduced graph of the inheritance chain(s)
1323        for (name, custom_profile) in self.other_profiles() {
1324            let starts_with_default = self.sanitize_custom_default_profile_inherits(
1325                name,
1326                custom_profile,
1327                inherit_err_collector,
1328            );
1329            if !starts_with_default {
1330                // We don't need to add default- profiles. Since they cannot
1331                // have inherits specified on them (they effectively always
1332                // inherit from default), they cannot participate in inheritance
1333                // cycles.
1334                self.add_profile_to_graph(
1335                    name,
1336                    custom_profile,
1337                    &mut profile_map,
1338                    &mut profile_graph,
1339                    inherit_err_collector,
1340                    known_profiles,
1341                );
1342            }
1343        }
1344
1345        self.check_inheritance_cycles(profile_graph, inherit_err_collector);
1346    }
1347
1348    /// Check any CustomProfileImpl that have a "default-" name and make sure they
1349    /// do not inherit from other profiles.
1350    fn sanitize_custom_default_profile_inherits(
1351        &self,
1352        name: &str,
1353        custom_profile: &CustomProfileImpl,
1354        inherit_err_collector: &mut Vec<InheritsError>,
1355    ) -> bool {
1356        let starts_with_default = name.starts_with("default-");
1357
1358        if starts_with_default && custom_profile.inherits().is_some() {
1359            inherit_err_collector.push(InheritsError::DefaultProfileInheritance(name.to_string()));
1360        }
1361
1362        starts_with_default
1363    }
1364
1365    /// Add the custom profile to the profile graph and collect any inheritance errors like
1366    /// self-referential profiles and nonexisting profiles.
1367    ///
1368    /// `known_profiles` contains profiles from previously loaded (lower priority) files.
1369    fn add_profile_to_graph<'cfg>(
1370        &self,
1371        name: &'cfg str,
1372        custom_profile: &'cfg CustomProfileImpl,
1373        profile_map: &mut HashMap<&'cfg str, NodeIndex>,
1374        profile_graph: &mut Graph<&'cfg str, ()>,
1375        inherit_err_collector: &mut Vec<InheritsError>,
1376        known_profiles: &BTreeSet<String>,
1377    ) {
1378        if let Some(inherits_name) = custom_profile.inherits() {
1379            if inherits_name == name {
1380                inherit_err_collector
1381                    .push(InheritsError::SelfReferentialInheritance(name.to_string()))
1382            } else if self.get_profile(inherits_name).is_ok() {
1383                // Inherited profile exists in this file -- create edge for cycle detection.
1384                let from_node = match profile_map.get(name) {
1385                    None => {
1386                        let profile_node = profile_graph.add_node(name);
1387                        profile_map.insert(name, profile_node);
1388                        profile_node
1389                    }
1390                    Some(node_idx) => *node_idx,
1391                };
1392                let to_node = match profile_map.get(inherits_name) {
1393                    None => {
1394                        let profile_node = profile_graph.add_node(inherits_name);
1395                        profile_map.insert(inherits_name, profile_node);
1396                        profile_node
1397                    }
1398                    Some(node_idx) => *node_idx,
1399                };
1400                profile_graph.add_edge(from_node, to_node, ());
1401            } else if known_profiles.contains(inherits_name) {
1402                // Inherited profile exists in a previously loaded file -- valid, no
1403                // cycle detection needed (cross-file cycles are impossible with
1404                // downward-only inheritance).
1405            } else {
1406                inherit_err_collector.push(InheritsError::UnknownInheritance(
1407                    name.to_string(),
1408                    inherits_name.to_string(),
1409                ))
1410            }
1411        }
1412    }
1413
1414    /// Given a profile graph, reports all SCC cycles within the graph using kosaraju algorithm.
1415    fn check_inheritance_cycles(
1416        &self,
1417        profile_graph: Graph<&str, ()>,
1418        inherit_err_collector: &mut Vec<InheritsError>,
1419    ) {
1420        let profile_sccs: Vec<Vec<NodeIndex>> = kosaraju_scc(&profile_graph);
1421        let profile_sccs: Vec<Vec<NodeIndex>> = profile_sccs
1422            .into_iter()
1423            .filter(|scc| scc.len() >= 2)
1424            .collect();
1425
1426        if !profile_sccs.is_empty() {
1427            inherit_err_collector.push(InheritsError::InheritanceCycle(
1428                profile_sccs
1429                    .iter()
1430                    .map(|node_idxs| {
1431                        let profile_names: Vec<String> = node_idxs
1432                            .iter()
1433                            .map(|node_idx| profile_graph[*node_idx].to_string())
1434                            .collect();
1435                        profile_names
1436                    })
1437                    .collect(),
1438            ));
1439        }
1440    }
1441}
1442
1443// This is the form of `NextestConfig` that gets deserialized.
1444#[derive(Clone, Debug, Deserialize)]
1445#[serde(rename_all = "kebab-case")]
1446struct NextestConfigDeserialize {
1447    store: StoreConfigImpl,
1448
1449    // These are parsed as part of NextestConfigVersionOnly. They're re-parsed here to avoid
1450    // printing an "unknown key" message.
1451    #[expect(unused)]
1452    #[serde(default)]
1453    nextest_version: Option<NextestVersionDeserialize>,
1454    #[expect(unused)]
1455    #[serde(default)]
1456    experimental: ExperimentalDeserialize,
1457
1458    #[serde(default)]
1459    test_groups: BTreeMap<CustomTestGroup, TestGroupConfig>,
1460    // Previous version of setup scripts, stored as "script.<name of script>".
1461    #[serde(default, rename = "script")]
1462    old_setup_scripts: IndexMap<ScriptId, SetupScriptConfig>,
1463    #[serde(default)]
1464    scripts: ScriptConfig,
1465    #[serde(rename = "profile")]
1466    profiles: HashMap<String, CustomProfileImpl>,
1467}
1468
1469impl NextestConfigDeserialize {
1470    fn into_config_impl(mut self) -> NextestConfigImpl {
1471        let p = self
1472            .profiles
1473            .remove("default")
1474            .expect("default profile should exist");
1475        let default_profile = DefaultProfileImpl::new(p);
1476
1477        // XXX: This is not quite right (doesn't obey precedence) but is okay
1478        // because it's unlikely folks are using the combination of setup
1479        // scripts *and* tools *and* relying on this. If it breaks, well, this
1480        // feature isn't stable.
1481        for (script_id, script_config) in self.old_setup_scripts {
1482            if let indexmap::map::Entry::Vacant(entry) = self.scripts.setup.entry(script_id) {
1483                entry.insert(script_config);
1484            }
1485        }
1486
1487        NextestConfigImpl {
1488            store: self.store,
1489            default_profile,
1490            test_groups: self.test_groups,
1491            scripts: self.scripts,
1492            other_profiles: self.profiles,
1493        }
1494    }
1495}
1496
1497#[derive(Clone, Debug, Deserialize)]
1498#[serde(rename_all = "kebab-case")]
1499struct StoreConfigImpl {
1500    dir: Utf8PathBuf,
1501}
1502
1503#[derive(Clone, Debug)]
1504pub(in crate::config) struct DefaultProfileImpl {
1505    default_filter: String,
1506    test_threads: TestThreads,
1507    threads_required: ThreadsRequired,
1508    run_extra_args: Vec<String>,
1509    retries: RetryPolicy,
1510    flaky_result: FlakyResult,
1511    status_level: StatusLevel,
1512    final_status_level: FinalStatusLevel,
1513    failure_output: TestOutputDisplay,
1514    success_output: TestOutputDisplay,
1515    max_fail: MaxFail,
1516    slow_timeout: SlowTimeout,
1517    global_timeout: GlobalTimeout,
1518    leak_timeout: LeakTimeout,
1519    overrides: Vec<DeserializedOverride>,
1520    scripts: Vec<DeserializedProfileScriptConfig>,
1521    junit: DefaultJunitImpl,
1522    archive: ArchiveConfig,
1523    bench: DefaultBenchConfig,
1524    inherits: Inherits,
1525}
1526
1527impl DefaultProfileImpl {
1528    fn new(p: CustomProfileImpl) -> Self {
1529        Self {
1530            default_filter: p
1531                .default_filter
1532                .expect("default-filter present in default profile"),
1533            test_threads: p
1534                .test_threads
1535                .expect("test-threads present in default profile"),
1536            threads_required: p
1537                .threads_required
1538                .expect("threads-required present in default profile"),
1539            run_extra_args: p
1540                .run_extra_args
1541                .expect("run-extra-args present in default profile"),
1542            retries: p.retries.expect("retries present in default profile"),
1543            flaky_result: p
1544                .flaky_result
1545                .expect("flaky-result present in default profile"),
1546            status_level: p
1547                .status_level
1548                .expect("status-level present in default profile"),
1549            final_status_level: p
1550                .final_status_level
1551                .expect("final-status-level present in default profile"),
1552            failure_output: p
1553                .failure_output
1554                .expect("failure-output present in default profile"),
1555            success_output: p
1556                .success_output
1557                .expect("success-output present in default profile"),
1558            max_fail: p.max_fail.expect("fail-fast present in default profile"),
1559            slow_timeout: p
1560                .slow_timeout
1561                .expect("slow-timeout present in default profile"),
1562            global_timeout: p
1563                .global_timeout
1564                .expect("global-timeout present in default profile"),
1565            leak_timeout: p
1566                .leak_timeout
1567                .expect("leak-timeout present in default profile"),
1568            overrides: p.overrides,
1569            scripts: p.scripts,
1570            junit: DefaultJunitImpl::for_default_profile(p.junit),
1571            archive: p.archive.expect("archive present in default profile"),
1572            bench: DefaultBenchConfig::for_default_profile(
1573                p.bench.expect("bench present in default profile"),
1574            ),
1575            inherits: Inherits::new(p.inherits),
1576        }
1577    }
1578
1579    pub(in crate::config) fn default_filter(&self) -> &str {
1580        &self.default_filter
1581    }
1582
1583    pub(in crate::config) fn inherits(&self) -> Option<&str> {
1584        self.inherits.inherits_from()
1585    }
1586
1587    pub(in crate::config) fn overrides(&self) -> &[DeserializedOverride] {
1588        &self.overrides
1589    }
1590
1591    pub(in crate::config) fn setup_scripts(&self) -> &[DeserializedProfileScriptConfig] {
1592        &self.scripts
1593    }
1594
1595    pub(in crate::config) fn slow_timeout(&self, run_mode: NextestRunMode) -> SlowTimeout {
1596        match run_mode {
1597            NextestRunMode::Test => self.slow_timeout,
1598            NextestRunMode::Benchmark => self.bench.slow_timeout,
1599        }
1600    }
1601
1602    pub(in crate::config) fn global_timeout(&self, run_mode: NextestRunMode) -> GlobalTimeout {
1603        match run_mode {
1604            NextestRunMode::Test => self.global_timeout,
1605            NextestRunMode::Benchmark => self.bench.global_timeout,
1606        }
1607    }
1608}
1609
1610#[derive(Clone, Debug, Deserialize)]
1611#[serde(rename_all = "kebab-case")]
1612pub(in crate::config) struct CustomProfileImpl {
1613    /// The default set of tests run by `cargo nextest run`.
1614    #[serde(default)]
1615    default_filter: Option<String>,
1616    #[serde(default, deserialize_with = "deserialize_retry_policy")]
1617    retries: Option<RetryPolicy>,
1618    #[serde(default)]
1619    flaky_result: Option<FlakyResult>,
1620    #[serde(default)]
1621    test_threads: Option<TestThreads>,
1622    #[serde(default)]
1623    threads_required: Option<ThreadsRequired>,
1624    #[serde(default)]
1625    run_extra_args: Option<Vec<String>>,
1626    #[serde(default)]
1627    status_level: Option<StatusLevel>,
1628    #[serde(default)]
1629    final_status_level: Option<FinalStatusLevel>,
1630    #[serde(default)]
1631    failure_output: Option<TestOutputDisplay>,
1632    #[serde(default)]
1633    success_output: Option<TestOutputDisplay>,
1634    #[serde(
1635        default,
1636        rename = "fail-fast",
1637        deserialize_with = "deserialize_fail_fast"
1638    )]
1639    max_fail: Option<MaxFail>,
1640    #[serde(default, deserialize_with = "deserialize_slow_timeout")]
1641    slow_timeout: Option<SlowTimeout>,
1642    #[serde(default)]
1643    global_timeout: Option<GlobalTimeout>,
1644    #[serde(default, deserialize_with = "deserialize_leak_timeout")]
1645    leak_timeout: Option<LeakTimeout>,
1646    #[serde(default)]
1647    overrides: Vec<DeserializedOverride>,
1648    #[serde(default)]
1649    scripts: Vec<DeserializedProfileScriptConfig>,
1650    #[serde(default)]
1651    junit: JunitImpl,
1652    #[serde(default)]
1653    archive: Option<ArchiveConfig>,
1654    #[serde(default)]
1655    bench: Option<BenchConfig>,
1656    #[serde(default)]
1657    inherits: Option<String>,
1658}
1659
1660impl CustomProfileImpl {
1661    #[cfg(test)]
1662    pub(in crate::config) fn test_threads(&self) -> Option<TestThreads> {
1663        self.test_threads
1664    }
1665
1666    pub(in crate::config) fn default_filter(&self) -> Option<&str> {
1667        self.default_filter.as_deref()
1668    }
1669
1670    pub(in crate::config) fn slow_timeout(&self, run_mode: NextestRunMode) -> Option<SlowTimeout> {
1671        match run_mode {
1672            NextestRunMode::Test => self.slow_timeout,
1673            NextestRunMode::Benchmark => self.bench.as_ref().and_then(|b| b.slow_timeout),
1674        }
1675    }
1676
1677    pub(in crate::config) fn global_timeout(
1678        &self,
1679        run_mode: NextestRunMode,
1680    ) -> Option<GlobalTimeout> {
1681        match run_mode {
1682            NextestRunMode::Test => self.global_timeout,
1683            NextestRunMode::Benchmark => self.bench.as_ref().and_then(|b| b.global_timeout),
1684        }
1685    }
1686
1687    pub(in crate::config) fn inherits(&self) -> Option<&str> {
1688        self.inherits.as_deref()
1689    }
1690
1691    pub(in crate::config) fn overrides(&self) -> &[DeserializedOverride] {
1692        &self.overrides
1693    }
1694
1695    pub(in crate::config) fn scripts(&self) -> &[DeserializedProfileScriptConfig] {
1696        &self.scripts
1697    }
1698}
1699
1700#[cfg(test)]
1701mod tests {
1702    use super::*;
1703    use crate::config::utils::test_helpers::*;
1704    use camino_tempfile::tempdir;
1705    use iddqd::{IdHashItem, IdHashMap, id_hash_map, id_upcast};
1706
1707    fn tool_name(s: &str) -> ToolName {
1708        ToolName::new(s.into()).unwrap()
1709    }
1710
1711    /// Test implementation of ConfigWarnings that collects warnings for testing.
1712    #[derive(Default)]
1713    struct TestConfigWarnings {
1714        unknown_keys: IdHashMap<UnknownKeys>,
1715        reserved_profiles: IdHashMap<ReservedProfiles>,
1716        deprecated_scripts: IdHashMap<DeprecatedScripts>,
1717        empty_script_warnings: IdHashMap<EmptyScriptSections>,
1718    }
1719
1720    impl ConfigWarnings for TestConfigWarnings {
1721        fn unknown_config_keys(
1722            &mut self,
1723            config_file: &Utf8Path,
1724            _workspace_root: &Utf8Path,
1725            tool: Option<&ToolName>,
1726            unknown: &BTreeSet<String>,
1727        ) {
1728            self.unknown_keys
1729                .insert_unique(UnknownKeys {
1730                    tool: tool.cloned(),
1731                    config_file: config_file.to_owned(),
1732                    keys: unknown.clone(),
1733                })
1734                .unwrap();
1735        }
1736
1737        fn unknown_reserved_profiles(
1738            &mut self,
1739            config_file: &Utf8Path,
1740            _workspace_root: &Utf8Path,
1741            tool: Option<&ToolName>,
1742            profiles: &[&str],
1743        ) {
1744            self.reserved_profiles
1745                .insert_unique(ReservedProfiles {
1746                    tool: tool.cloned(),
1747                    config_file: config_file.to_owned(),
1748                    profiles: profiles.iter().map(|&s| s.to_owned()).collect(),
1749                })
1750                .unwrap();
1751        }
1752
1753        fn empty_script_sections(
1754            &mut self,
1755            config_file: &Utf8Path,
1756            _workspace_root: &Utf8Path,
1757            tool: Option<&ToolName>,
1758            profile_name: &str,
1759            empty_count: usize,
1760        ) {
1761            self.empty_script_warnings
1762                .insert_unique(EmptyScriptSections {
1763                    tool: tool.cloned(),
1764                    config_file: config_file.to_owned(),
1765                    profile_name: profile_name.to_owned(),
1766                    empty_count,
1767                })
1768                .unwrap();
1769        }
1770
1771        fn deprecated_script_config(
1772            &mut self,
1773            config_file: &Utf8Path,
1774            _workspace_root: &Utf8Path,
1775            tool: Option<&ToolName>,
1776        ) {
1777            self.deprecated_scripts
1778                .insert_unique(DeprecatedScripts {
1779                    tool: tool.cloned(),
1780                    config_file: config_file.to_owned(),
1781                })
1782                .unwrap();
1783        }
1784    }
1785
1786    #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
1787    struct UnknownKeys {
1788        tool: Option<ToolName>,
1789        config_file: Utf8PathBuf,
1790        keys: BTreeSet<String>,
1791    }
1792
1793    impl IdHashItem for UnknownKeys {
1794        type Key<'a> = Option<&'a ToolName>;
1795        fn key(&self) -> Self::Key<'_> {
1796            self.tool.as_ref()
1797        }
1798        id_upcast!();
1799    }
1800
1801    #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
1802    struct ReservedProfiles {
1803        tool: Option<ToolName>,
1804        config_file: Utf8PathBuf,
1805        profiles: Vec<String>,
1806    }
1807
1808    impl IdHashItem for ReservedProfiles {
1809        type Key<'a> = Option<&'a ToolName>;
1810        fn key(&self) -> Self::Key<'_> {
1811            self.tool.as_ref()
1812        }
1813        id_upcast!();
1814    }
1815
1816    #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
1817    struct DeprecatedScripts {
1818        tool: Option<ToolName>,
1819        config_file: Utf8PathBuf,
1820    }
1821
1822    impl IdHashItem for DeprecatedScripts {
1823        type Key<'a> = Option<&'a ToolName>;
1824        fn key(&self) -> Self::Key<'_> {
1825            self.tool.as_ref()
1826        }
1827        id_upcast!();
1828    }
1829
1830    #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
1831    struct EmptyScriptSections {
1832        tool: Option<ToolName>,
1833        config_file: Utf8PathBuf,
1834        profile_name: String,
1835        empty_count: usize,
1836    }
1837
1838    impl IdHashItem for EmptyScriptSections {
1839        type Key<'a> = (&'a Option<ToolName>, &'a str);
1840        fn key(&self) -> Self::Key<'_> {
1841            (&self.tool, &self.profile_name)
1842        }
1843        id_upcast!();
1844    }
1845
1846    #[test]
1847    fn default_config_is_valid() {
1848        let default_config = NextestConfig::default_config("foo");
1849        default_config
1850            .profile(NextestConfig::DEFAULT_PROFILE)
1851            .expect("default profile should exist");
1852    }
1853
1854    #[test]
1855    fn ignored_keys() {
1856        let config_contents = r#"
1857        ignored1 = "test"
1858
1859        [profile.default]
1860        retries = 3
1861        ignored2 = "hi"
1862
1863        [profile.default-foo]
1864        retries = 5
1865
1866        [[profile.default.overrides]]
1867        filter = 'test(test_foo)'
1868        retries = 20
1869        ignored3 = 42
1870        "#;
1871
1872        let tool_config_contents = r#"
1873        [store]
1874        ignored4 = 20
1875
1876        [profile.default]
1877        retries = 4
1878        ignored5 = false
1879
1880        [profile.default-bar]
1881        retries = 5
1882
1883        [profile.tool]
1884        retries = 12
1885
1886        [[profile.tool.overrides]]
1887        filter = 'test(test_baz)'
1888        retries = 22
1889        ignored6 = 6.5
1890        "#;
1891
1892        let workspace_dir = tempdir().unwrap();
1893
1894        let graph = temp_workspace(&workspace_dir, config_contents);
1895        let workspace_root = graph.workspace().root();
1896        let tool_path = workspace_root.join(".config/tool.toml");
1897        std::fs::write(&tool_path, tool_config_contents).unwrap();
1898
1899        let pcx = ParseContext::new(&graph);
1900
1901        let mut warnings = TestConfigWarnings::default();
1902
1903        let _ = NextestConfig::from_sources_with_warnings(
1904            workspace_root,
1905            &pcx,
1906            None,
1907            &[ToolConfigFile {
1908                tool: tool_name("my-tool"),
1909                config_file: tool_path.clone(),
1910            }][..],
1911            &Default::default(),
1912            &mut warnings,
1913        )
1914        .expect("config is valid");
1915
1916        assert_eq!(
1917            warnings.unknown_keys.len(),
1918            2,
1919            "there are two files with unknown keys"
1920        );
1921
1922        assert_eq!(
1923            warnings.unknown_keys,
1924            id_hash_map! {
1925                UnknownKeys {
1926                    tool: None,
1927                    config_file: workspace_root.join(".config/nextest.toml"),
1928                    keys: maplit::btreeset! {
1929                        "ignored1".to_owned(),
1930                        "profile.default.ignored2".to_owned(),
1931                        "profile.default.overrides.0.ignored3".to_owned(),
1932                    }
1933                },
1934                UnknownKeys {
1935                    tool: Some(tool_name("my-tool")),
1936                    config_file: tool_path.clone(),
1937                    keys: maplit::btreeset! {
1938                        "store.ignored4".to_owned(),
1939                        "profile.default.ignored5".to_owned(),
1940                        "profile.tool.overrides.0.ignored6".to_owned(),
1941                    }
1942                }
1943            }
1944        );
1945        assert_eq!(
1946            warnings.reserved_profiles,
1947            id_hash_map! {
1948                ReservedProfiles {
1949                    tool: None,
1950                    config_file: workspace_root.join(".config/nextest.toml"),
1951                    profiles: vec!["default-foo".to_owned()],
1952                },
1953                ReservedProfiles {
1954                    tool: Some(tool_name("my-tool")),
1955                    config_file: tool_path,
1956                    profiles: vec!["default-bar".to_owned()],
1957                }
1958            },
1959        )
1960    }
1961
1962    #[test]
1963    fn script_warnings() {
1964        let config_contents = r#"
1965        experimental = ["setup-scripts", "wrapper-scripts"]
1966
1967        [scripts.wrapper.script1]
1968        command = "echo test"
1969
1970        [scripts.wrapper.script2]
1971        command = "echo test2"
1972
1973        [scripts.setup.script3]
1974        command = "echo setup"
1975
1976        [[profile.default.scripts]]
1977        filter = 'all()'
1978        # Empty - no setup or wrapper scripts
1979
1980        [[profile.default.scripts]]
1981        filter = 'test(foo)'
1982        setup = ["script3"]
1983
1984        [profile.custom]
1985        [[profile.custom.scripts]]
1986        filter = 'all()'
1987        # Empty - no setup or wrapper scripts
1988
1989        [[profile.custom.scripts]]
1990        filter = 'test(bar)'
1991        # Another empty section
1992        "#;
1993
1994        let tool_config_contents = r#"
1995        experimental = ["setup-scripts", "wrapper-scripts"]
1996
1997        [scripts.wrapper."@tool:tool:disabled_script"]
1998        command = "echo disabled"
1999
2000        [scripts.setup."@tool:tool:setup_script"]
2001        command = "echo setup"
2002
2003        [profile.tool]
2004        [[profile.tool.scripts]]
2005        filter = 'all()'
2006        # Empty section
2007
2008        [[profile.tool.scripts]]
2009        filter = 'test(foo)'
2010        setup = ["@tool:tool:setup_script"]
2011        "#;
2012
2013        let workspace_dir = tempdir().unwrap();
2014        let graph = temp_workspace(&workspace_dir, config_contents);
2015        let workspace_root = graph.workspace().root();
2016        let tool_path = workspace_root.join(".config/tool.toml");
2017        std::fs::write(&tool_path, tool_config_contents).unwrap();
2018
2019        let pcx = ParseContext::new(&graph);
2020
2021        let mut warnings = TestConfigWarnings::default();
2022
2023        let experimental = maplit::btreeset! {
2024            ConfigExperimental::SetupScripts,
2025            ConfigExperimental::WrapperScripts
2026        };
2027        let _ = NextestConfig::from_sources_with_warnings(
2028            workspace_root,
2029            &pcx,
2030            None,
2031            &[ToolConfigFile {
2032                tool: tool_name("tool"),
2033                config_file: tool_path.clone(),
2034            }][..],
2035            &experimental,
2036            &mut warnings,
2037        )
2038        .expect("config is valid");
2039
2040        assert_eq!(
2041            warnings.empty_script_warnings,
2042            id_hash_map! {
2043                EmptyScriptSections {
2044                    tool: None,
2045                    config_file: workspace_root.join(".config/nextest.toml"),
2046                    profile_name: "default".to_owned(),
2047                    empty_count: 1,
2048                },
2049                EmptyScriptSections {
2050                    tool: None,
2051                    config_file: workspace_root.join(".config/nextest.toml"),
2052                    profile_name: "custom".to_owned(),
2053                    empty_count: 2,
2054                },
2055                EmptyScriptSections {
2056                    tool: Some(tool_name("tool")),
2057                    config_file: tool_path,
2058                    profile_name: "tool".to_owned(),
2059                    empty_count: 1,
2060                }
2061            }
2062        );
2063    }
2064
2065    #[test]
2066    fn deprecated_script_config_warning() {
2067        let config_contents = r#"
2068        experimental = ["setup-scripts"]
2069
2070        [script.my-script]
2071        command = "echo hello"
2072"#;
2073
2074        let tool_config_contents = r#"
2075        experimental = ["setup-scripts"]
2076
2077        [script."@tool:my-tool:my-script"]
2078        command = "echo hello"
2079"#;
2080
2081        let temp_dir = tempdir().unwrap();
2082
2083        let graph = temp_workspace(&temp_dir, config_contents);
2084        let workspace_root = graph.workspace().root();
2085        let tool_path = workspace_root.join(".config/my-tool.toml");
2086        std::fs::write(&tool_path, tool_config_contents).unwrap();
2087        let pcx = ParseContext::new(&graph);
2088
2089        let mut warnings = TestConfigWarnings::default();
2090        NextestConfig::from_sources_with_warnings(
2091            graph.workspace().root(),
2092            &pcx,
2093            None,
2094            &[ToolConfigFile {
2095                tool: tool_name("my-tool"),
2096                config_file: tool_path.clone(),
2097            }],
2098            &maplit::btreeset! {ConfigExperimental::SetupScripts},
2099            &mut warnings,
2100        )
2101        .expect("config is valid");
2102
2103        assert_eq!(
2104            warnings.deprecated_scripts,
2105            id_hash_map! {
2106                DeprecatedScripts {
2107                    tool: None,
2108                    config_file: graph.workspace().root().join(".config/nextest.toml"),
2109                },
2110                DeprecatedScripts {
2111                    tool: Some(tool_name("my-tool")),
2112                    config_file: tool_path,
2113                }
2114            }
2115        );
2116    }
2117}