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