nextest_runner/config/core/
imp.rs

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