Skip to main content

nextest_runner/config/core/
imp.rs

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