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