nextest_runner/config/elements/
inherits.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4/// Inherit settings for profiles.
5#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
6pub struct Inherits(Option<String>);
7
8impl Inherits {
9    /// Creates a new `Inherits`.
10    pub fn new(inherits: Option<String>) -> Self {
11        Self(inherits)
12    }
13
14    /// Returns the profile that the custom profile inherits from.
15    pub fn inherits_from(&self) -> Option<&str> {
16        self.0.as_deref()
17    }
18}
19
20#[cfg(test)]
21mod tests {
22    use crate::{
23        config::{
24            core::{NextestConfig, ToolConfigFile, ToolName},
25            elements::{MaxFail, RetryPolicy, TerminateMode},
26            utils::test_helpers::*,
27        },
28        errors::{
29            ConfigParseErrorKind,
30            InheritsError::{self, *},
31        },
32    };
33    use camino_tempfile::tempdir;
34    use indoc::indoc;
35    use nextest_filtering::ParseContext;
36    use std::{collections::HashSet, fs};
37    use test_case::test_case;
38
39    fn tool_name(s: &str) -> ToolName {
40        ToolName::new(s.into()).unwrap()
41    }
42
43    /// Settings checked for inheritance below.
44    #[derive(Default)]
45    pub struct InheritSettings {
46        name: String,
47        inherits: Option<String>,
48        max_fail: Option<MaxFail>,
49        retries: Option<RetryPolicy>,
50    }
51
52    #[test_case(
53        indoc! {r#"
54            [profile.prof_a]
55            inherits = "prof_b"
56
57            [profile.prof_b]
58            inherits = "prof_c"
59            fail-fast = { max-fail = 4 }
60
61            [profile.prof_c]
62            inherits = "default"
63            fail-fast = { max-fail = 10 }
64            retries = 3
65        "#},
66        Ok(InheritSettings {
67            name: "prof_a".to_string(),
68            inherits: Some("prof_b".to_string()),
69            // prof_b's max-fail (4) should override prof_c's (10)
70            max_fail: Some(MaxFail::Count { max_fail: 4, terminate: TerminateMode::Wait }),
71            // prof_c's retries should be inherited through prof_b
72            retries: Some(RetryPolicy::new_without_delay(3)),
73        })
74        ; "three-level inheritance"
75    )]
76    #[test_case(
77        indoc! {r#"
78            [profile.prof_a]
79            inherits = "prof_b"
80
81            [profile.prof_b]
82            inherits = "prof_c"
83
84            [profile.prof_c]
85            inherits = "prof_c"
86        "#},
87        Err(
88            vec![
89                InheritsError::SelfReferentialInheritance("prof_c".to_string()),
90            ]
91        ) ; "self referential error not inheritance cycle"
92    )]
93    #[test_case(
94        indoc! {r#"
95            [profile.prof_a]
96            inherits = "prof_b"
97
98            [profile.prof_b]
99            inherits = "prof_c"
100
101            [profile.prof_c]
102            inherits = "prof_d"
103
104            [profile.prof_d]
105            inherits = "prof_e"
106
107            [profile.prof_e]
108            inherits = "prof_c"
109        "#},
110        Err(
111            vec![
112                InheritsError::InheritanceCycle(
113                    vec![vec!["prof_c".to_string(),"prof_d".to_string(), "prof_e".to_string()]],
114                ),
115            ]
116        ) ; "C to D to E SCC cycle"
117    )]
118    #[test_case(
119        indoc! {r#"
120            [profile.default]
121            inherits = "prof_a"
122
123            [profile.default-miri]
124            inherits = "prof_c"
125
126            [profile.prof_a]
127            inherits = "prof_b"
128
129            [profile.prof_b]
130            inherits = "prof_c"
131
132            [profile.prof_c]
133            inherits = "prof_a"
134
135            [profile.prof_d]
136            inherits = "prof_d"
137
138            [profile.prof_e]
139            inherits = "nonexistent_profile"
140        "#},
141        Err(
142            vec![
143                InheritsError::DefaultProfileInheritance("default".to_string()),
144                InheritsError::DefaultProfileInheritance("default-miri".to_string()),
145                InheritsError::SelfReferentialInheritance("prof_d".to_string()),
146                InheritsError::UnknownInheritance(
147                    "prof_e".to_string(),
148                    "nonexistent_profile".to_string(),
149                ),
150                InheritsError::InheritanceCycle(
151                    vec![
152                        vec!["prof_a".to_string(),"prof_b".to_string(), "prof_c".to_string()],
153                    ]
154                ),
155            ]
156        )
157        ; "inheritance errors detected"
158    )]
159    #[test_case(
160        indoc! {r#"
161            [profile.my-profile]
162            inherits = "default-nonexistent"
163            retries = 5
164        "#},
165        Err(
166            vec![
167                InheritsError::UnknownInheritance(
168                    "my-profile".to_string(),
169                    "default-nonexistent".to_string(),
170                ),
171            ]
172        )
173        ; "inherit from nonexistent default profile"
174    )]
175    #[test_case(
176        indoc! {r#"
177            [profile.default-custom]
178            retries = 3
179
180            [profile.my-profile]
181            inherits = "default-custom"
182            fail-fast = { max-fail = 5 }
183        "#},
184        Ok(InheritSettings {
185            name: "my-profile".to_string(),
186            inherits: Some("default-custom".to_string()),
187            max_fail: Some(MaxFail::Count { max_fail: 5, terminate: TerminateMode::Wait }),
188            retries: Some(RetryPolicy::new_without_delay(3)),
189        })
190        ; "inherit from defined default profile"
191    )]
192    fn profile_inheritance(
193        config_contents: &str,
194        expected: Result<InheritSettings, Vec<InheritsError>>,
195    ) {
196        let workspace_dir = tempdir().unwrap();
197        let graph = temp_workspace(&workspace_dir, config_contents);
198        let pcx = ParseContext::new(&graph);
199
200        let config_res = NextestConfig::from_sources(
201            graph.workspace().root(),
202            &pcx,
203            None,
204            [],
205            &Default::default(),
206        );
207
208        match expected {
209            Ok(custom_profile) => {
210                let config = config_res.expect("config is valid");
211                let default_profile = config
212                    .profile("default")
213                    .unwrap_or_else(|_| panic!("default profile is known"));
214                let default_profile = default_profile.apply_build_platforms(&build_platforms());
215                let profile = config
216                    .profile(&custom_profile.name)
217                    .unwrap_or_else(|_| panic!("{} profile is known", &custom_profile.name));
218                let profile = profile.apply_build_platforms(&build_platforms());
219                assert_eq!(default_profile.inherits(), None);
220                assert_eq!(profile.inherits(), custom_profile.inherits.as_deref());
221
222                // Spot check that inheritance works correctly.
223                assert_eq!(
224                    profile.max_fail(),
225                    custom_profile.max_fail.expect("max fail should exist")
226                );
227                if let Some(expected_retries) = custom_profile.retries {
228                    assert_eq!(profile.retries(), expected_retries);
229                }
230            }
231            Err(expected_inherits_err) => {
232                let error = config_res.expect_err("config is invalid");
233                assert_eq!(error.tool(), None);
234                match error.kind() {
235                    ConfigParseErrorKind::InheritanceErrors(inherits_err) => {
236                        // Because inheritance errors are not in a deterministic
237                        // order in the Vec<InheritsError>, we use a HashSet
238                        // here to test whether the error seen by the expected
239                        // err.
240                        let expected_err: HashSet<&InheritsError> =
241                            expected_inherits_err.iter().collect();
242                        for actual_err in inherits_err.iter() {
243                            match actual_err {
244                                InheritanceCycle(sccs) => {
245                                    // SCC vectors do show the cycle, but
246                                    // we can't deterministically represent the cycle
247                                    // (i.e. A->B->C->A could be {A,B,C}, {C,A,B}, or
248                                    // {B,C,A})
249                                    let mut sccs = sccs.clone();
250                                    for scc in sccs.iter_mut() {
251                                        scc.sort()
252                                    }
253                                    assert!(
254                                        expected_err.contains(&InheritanceCycle(sccs)),
255                                        "unexpected inherit error {:?}",
256                                        actual_err
257                                    )
258                                }
259                                _ => {
260                                    assert!(
261                                        expected_err.contains(&actual_err),
262                                        "unexpected inherit error {:?}",
263                                        actual_err
264                                    )
265                                }
266                            }
267                        }
268                    }
269                    other => {
270                        panic!("expected ConfigParseErrorKind::InheritanceErrors, got {other}")
271                    }
272                }
273            }
274        }
275    }
276
277    /// Test that higher-priority files can inherit from lower-priority files.
278    #[test]
279    fn valid_downward_inheritance() {
280        let workspace_dir = tempdir().unwrap();
281
282        // Tool config 1 (higher priority): defines prof_a inheriting from prof_b
283        let tool1_config = workspace_dir.path().join("tool1.toml");
284        fs::write(
285            &tool1_config,
286            indoc! {r#"
287                    [profile.prof_a]
288                    inherits = "prof_b"
289                    retries = 5
290                "#},
291        )
292        .unwrap();
293
294        // Tool config 2 (lower priority): defines prof_b
295        let tool2_config = workspace_dir.path().join("tool2.toml");
296        fs::write(
297            &tool2_config,
298            indoc! {r#"
299                    [profile.prof_b]
300                    retries = 3
301                "#},
302        )
303        .unwrap();
304
305        let workspace_config = indoc! {r#"
306                [profile.default]
307            "#};
308
309        let graph = temp_workspace(&workspace_dir, workspace_config);
310        let pcx = ParseContext::new(&graph);
311
312        // tool1 is first = higher priority, tool2 is second = lower priority
313        let tool_configs = [
314            ToolConfigFile {
315                tool: tool_name("tool1"),
316                config_file: tool1_config,
317            },
318            ToolConfigFile {
319                tool: tool_name("tool2"),
320                config_file: tool2_config,
321            },
322        ];
323
324        let config = NextestConfig::from_sources(
325            graph.workspace().root(),
326            &pcx,
327            None,
328            &tool_configs,
329            &Default::default(),
330        )
331        .expect("config should be valid");
332
333        // prof_a should inherit retries=3 from prof_b, but override with retries=5
334        let profile = config
335            .profile("prof_a")
336            .unwrap()
337            .apply_build_platforms(&build_platforms());
338        assert_eq!(profile.retries(), RetryPolicy::new_without_delay(5));
339
340        // prof_b should have retries=3
341        let profile = config
342            .profile("prof_b")
343            .unwrap()
344            .apply_build_platforms(&build_platforms());
345        assert_eq!(profile.retries(), RetryPolicy::new_without_delay(3));
346    }
347
348    /// Test that lower-priority files cannot inherit from higher-priority files.
349    /// This is reported as an unknown profile error.
350    #[test]
351    fn invalid_upward_inheritance() {
352        let workspace_dir = tempdir().unwrap();
353
354        // Tool config 1 (higher priority): defines prof_a
355        let tool1_config = workspace_dir.path().join("tool1.toml");
356        fs::write(
357            &tool1_config,
358            indoc! {r#"
359                    [profile.prof_a]
360                    retries = 5
361                "#},
362        )
363        .unwrap();
364
365        // Tool config 2 (lower priority): tries to inherit from prof_a (not yet loaded)
366        let tool2_config = workspace_dir.path().join("tool2.toml");
367        fs::write(
368            &tool2_config,
369            indoc! {r#"
370                    [profile.prof_b]
371                    inherits = "prof_a"
372                "#},
373        )
374        .unwrap();
375
376        let workspace_config = indoc! {r#"
377                [profile.default]
378            "#};
379
380        let graph = temp_workspace(&workspace_dir, workspace_config);
381        let pcx = ParseContext::new(&graph);
382
383        let tool_configs = [
384            ToolConfigFile {
385                tool: tool_name("tool1"),
386                config_file: tool1_config,
387            },
388            ToolConfigFile {
389                tool: tool_name("tool2"),
390                config_file: tool2_config,
391            },
392        ];
393
394        let error = NextestConfig::from_sources(
395            graph.workspace().root(),
396            &pcx,
397            None,
398            &tool_configs,
399            &Default::default(),
400        )
401        .expect_err("config should fail: upward inheritance not allowed");
402
403        // Error should be attributed to tool2 since that's where the invalid
404        // inheritance is defined.
405        assert_eq!(error.tool(), Some(&tool_name("tool2")));
406
407        match error.kind() {
408            ConfigParseErrorKind::InheritanceErrors(errors) => {
409                assert_eq!(errors.len(), 1);
410                assert!(
411                    matches!(
412                        &errors[0],
413                        InheritsError::UnknownInheritance(from, to)
414                        if from == "prof_b" && to == "prof_a"
415                    ),
416                    "expected UnknownInheritance(prof_b, prof_a), got {:?}",
417                    errors[0]
418                );
419            }
420            other => {
421                panic!("expected ConfigParseErrorKind::InheritanceErrors, got {other}")
422            }
423        }
424    }
425}