Skip to main content

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