diff --git a/Cargo.lock b/Cargo.lock index 19335cd7422..a7be9c6b1b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2106,6 +2106,7 @@ dependencies = [ "nix", "owo-colors", "pathdiff", + "petgraph", "pin-project-lite", "pretty_assertions", "proptest", @@ -2372,6 +2373,7 @@ dependencies = [ "fixedbitset", "hashbrown 0.15.4", "indexmap 2.11.1", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5a934939b00..d11786fd79e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,6 +121,7 @@ strip-ansi-escapes = "0.2.1" supports-color = "3.0.2" supports-unicode = "3.0.0" swrite = "0.1.0" +petgraph = "0.8.2" tar = "0.4.44" target-spec = { version = "3.4.2", features = ["custom", "summaries"] } target-spec-miette = "0.4.4" diff --git a/nextest-runner/Cargo.toml b/nextest-runner/Cargo.toml index 6c49488544f..39eb717ae57 100644 --- a/nextest-runner/Cargo.toml +++ b/nextest-runner/Cargo.toml @@ -63,6 +63,7 @@ smol_str = { workspace = true, features = ["serde"] } strip-ansi-escapes.workspace = true supports-unicode.workspace = true swrite.workspace = true +petgraph.workspace = true tar.workspace = true # For cfg expression evaluation for [target.'cfg()'] expressions target-spec.workspace = true diff --git a/nextest-runner/src/config/core/imp.rs b/nextest-runner/src/config/core/imp.rs index 98b92000c67..c6ae15d372e 100644 --- a/nextest-runner/src/config/core/imp.rs +++ b/nextest-runner/src/config/core/imp.rs @@ -37,9 +37,10 @@ use config::{ use iddqd::IdOrdMap; use indexmap::IndexMap; use nextest_filtering::{BinaryQuery, EvalContext, Filterset, ParseContext, TestQuery}; +use petgraph::{algo::toposort, Directed, Graph}; use serde::Deserialize; use std::{ - collections::{BTreeMap, BTreeSet, HashMap, hash_map}, + collections::{hash_map, BTreeMap, BTreeSet, HashMap, HashSet}, sync::LazyLock, }; use tracing::warn; @@ -792,7 +793,15 @@ impl NextestConfig { } fn make_profile(&self, name: &str) -> Result, ProfileNotFound> { + // Check for cycles first + self.inner.check_inheritance_cycles().unwrap(); + let custom_profile = self.inner.get_profile(name)?; + let inheritance_chain = if let Some(_) = custom_profile { + self.inner.resolve_profile_chain(name)? + } else { + Vec::new() + }; // The profile was found: construct it. let mut store_dir = self.workspace_root.join(&self.inner.store.dir); @@ -809,6 +818,7 @@ impl NextestConfig { store_dir, default_profile: &self.inner.default_profile, custom_profile, + inheritance_chain, test_groups: &self.inner.test_groups, scripts: &self.inner.scripts, compiled_data, @@ -874,6 +884,7 @@ pub struct EarlyProfile<'cfg> { store_dir: Utf8PathBuf, default_profile: &'cfg DefaultProfileImpl, custom_profile: Option<&'cfg CustomProfileImpl>, + inheritance_chain: Vec<&'cfg CustomProfileImpl>, test_groups: &'cfg BTreeMap, // This is ordered because the scripts are used in the order they're defined. scripts: &'cfg ScriptConfig, @@ -924,6 +935,7 @@ impl<'cfg> EarlyProfile<'cfg> { store_dir: self.store_dir, default_profile: self.default_profile, custom_profile: self.custom_profile, + inheritance_chain: self.inheritance_chain, scripts: self.scripts, test_groups: self.test_groups, compiled_data, @@ -941,6 +953,7 @@ pub struct EvaluatableProfile<'cfg> { store_dir: Utf8PathBuf, default_profile: &'cfg DefaultProfileImpl, custom_profile: Option<&'cfg CustomProfileImpl>, + inheritance_chain: Vec<&'cfg CustomProfileImpl>, // Add this test_groups: &'cfg BTreeMap, // This is ordered because the scripts are used in the order they're defined. scripts: &'cfg ScriptConfig, @@ -984,11 +997,24 @@ impl<'cfg> EvaluatableProfile<'cfg> { self.scripts } - /// Returns the retry count for this profile. + /// Returns the retry count for this profile, considering inheritance pub fn retries(&self) -> RetryPolicy { - self.custom_profile - .and_then(|profile| profile.retries) - .unwrap_or(self.default_profile.retries) + // Check custom profile first, then walk up inheritance chain + if let Some(profile) = self.custom_profile { + if let Some(retries) = profile.retries { + return retries; + } + } + + // Walk up inheritance chain + for parent in &self.inheritance_chain { + if let Some(retries) = parent.retries { + return retries; + } + } + + // Fall back to default + self.default_profile.retries } /// Returns the number of threads to run against for this profile. @@ -1124,6 +1150,71 @@ pub(in crate::config) struct NextestConfigImpl { } impl NextestConfigImpl { + /// Resolves a profile with inheritance chain + fn resolve_profile_chain(&self, profile_name: &str) -> Result, ProfileNotFound> { + let mut visited = HashSet::new(); + let mut chain = Vec::new(); + + self.resolve_profile_chain_recursive(profile_name, &mut visited, &mut chain)?; + Ok(chain) + } + + fn resolve_profile_chain_recursive<'cfg>( + &'cfg self, + profile_name: &str, + visited: &mut HashSet, + chain: &mut Vec<&'cfg CustomProfileImpl>, + ) -> Result<(), ProfileNotFound> { + if visited.contains(profile_name) { + return Err(ProfileNotFound::new( + profile_name, + self.all_profiles().collect::>(), + )); + } + + visited.insert(profile_name.to_string()); + + let profile = self.get_profile(profile_name)?; + if let Some(profile) = profile { + if let Some(parent_name) = &profile.inherit { + self.resolve_profile_chain_recursive(parent_name, visited, chain)?; + } + chain.push(profile); + } + + Ok(()) + } + + fn check_inheritance_cycles(&self) -> Result<(), ConfigParseError> { + let mut graph = Graph::::new(); + let mut node_map = HashMap::new(); + + for profile in self.all_profiles() { + let node = graph.add_node(profile.to_string()); + node_map.insert(profile.to_string(), node); + } + + for (profile_name, profile) in &self.other_profiles { + if let Some(inherit_name) = &profile.inherit { + if let (Some(&from), Some(&to)) = (node_map.get(inherit_name), node_map.get(profile_name)) { + graph.add_edge(from, to, ()); + } + } + } + + match toposort(&graph, None) { + Ok(_) => Ok(()), + Err(cycle) => { + let cycle_profile = graph[cycle.node_id()].clone(); + Err(ConfigParseError::new( + "Inheritance cycle detected in profile configuration", + None, + ConfigParseErrorKind::InheritanceCycle(cycle_profile), + )) + } + } + } + fn get_profile(&self, profile: &str) -> Result, ProfileNotFound> { let custom_profile = match profile { NextestConfig::DEFAULT_PROFILE => None, @@ -1337,6 +1428,8 @@ pub(in crate::config) struct CustomProfileImpl { junit: JunitImpl, #[serde(default)] archive: Option, + #[serde(default)] + inherit: Option } impl CustomProfileImpl { diff --git a/nextest-runner/src/errors.rs b/nextest-runner/src/errors.rs index a96fbc16ab6..af48f29199f 100644 --- a/nextest-runner/src/errors.rs +++ b/nextest-runner/src/errors.rs @@ -178,6 +178,9 @@ pub enum ConfigParseErrorKind { /// The features that were not enabled. missing_features: BTreeSet, }, + /// An inheritance cycle was detected in the profile configuration. + #[error("inheritance cycle detected in profile configuration: {0}")] + InheritanceCycle(String), } /// An error that occurred while compiling overrides or scripts specified in