nextest_runner/config/core/
imp.rs

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