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