nextest_runner/config/
config_impl.rs

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