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