nextest_runner/config/
archive.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use super::TrackDefault;
5use crate::config::helpers::deserialize_relative_path;
6use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
7use serde::{Deserialize, de::Unexpected};
8use std::fmt;
9
10/// Configuration for archives.
11#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "kebab-case")]
13pub struct ArchiveConfig {
14    /// Files to include in the archive.
15    pub include: Vec<ArchiveInclude>,
16}
17
18/// Type for the archive-include key.
19///
20/// # Notes
21///
22/// This is `deny_unknown_fields` because if we take additional arguments in the future, they're
23/// likely to change semantics in an incompatible way.
24#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "kebab-case", deny_unknown_fields)]
26pub struct ArchiveInclude {
27    // We only allow well-formed relative paths within the target directory here. It's possible we
28    // can relax this in the future, but better safe than sorry for now.
29    #[serde(deserialize_with = "deserialize_relative_path")]
30    path: Utf8PathBuf,
31    relative_to: ArchiveRelativeTo,
32    #[serde(default = "default_depth")]
33    depth: TrackDefault<RecursionDepth>,
34    #[serde(default = "default_on_missing")]
35    on_missing: ArchiveIncludeOnMissing,
36}
37
38impl ArchiveInclude {
39    /// The maximum depth of recursion.
40    pub fn depth(&self) -> RecursionDepth {
41        self.depth.value
42    }
43
44    /// Whether the depth was deserialized. If false, the default value was used.
45    pub fn is_depth_deserialized(&self) -> bool {
46        self.depth.is_deserialized
47    }
48
49    /// Join the path with the given target dir.
50    pub fn join_path(&self, target_dir: &Utf8Path) -> Utf8PathBuf {
51        match self.relative_to {
52            ArchiveRelativeTo::Target => join_rel_path(target_dir, &self.path),
53        }
54    }
55
56    /// What to do when the path is missing.
57    pub fn on_missing(&self) -> ArchiveIncludeOnMissing {
58        self.on_missing
59    }
60}
61
62fn default_depth() -> TrackDefault<RecursionDepth> {
63    // We use a high-but-not-infinite depth.
64    TrackDefault::with_default_value(RecursionDepth::Finite(16))
65}
66
67fn default_on_missing() -> ArchiveIncludeOnMissing {
68    ArchiveIncludeOnMissing::Warn
69}
70
71fn join_rel_path(a: &Utf8Path, rel: &Utf8Path) -> Utf8PathBuf {
72    // This joins the subset of components that deserialize_relative_path
73    // allows. We also always use "/" to ensure consistency across platforms.
74    let mut out = String::from(a.to_owned());
75
76    for component in rel.components() {
77        match component {
78            Utf8Component::CurDir => {}
79            Utf8Component::Normal(p) => {
80                out.push('/');
81                out.push_str(p);
82            }
83            other => unreachable!(
84                "found invalid component {other:?}, deserialize_relative_path should have errored"
85            ),
86        }
87    }
88
89    out.into()
90}
91
92/// What to do when an archive-include path is missing.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum ArchiveIncludeOnMissing {
95    /// Ignore and continue.
96    Ignore,
97
98    /// Warn and continue.
99    Warn,
100
101    /// Produce an error.
102    Error,
103}
104
105impl<'de> Deserialize<'de> for ArchiveIncludeOnMissing {
106    fn deserialize<D>(deserializer: D) -> Result<ArchiveIncludeOnMissing, D::Error>
107    where
108        D: serde::Deserializer<'de>,
109    {
110        struct ArchiveIncludeOnMissingVisitor;
111
112        impl serde::de::Visitor<'_> for ArchiveIncludeOnMissingVisitor {
113            type Value = ArchiveIncludeOnMissing;
114
115            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
116                formatter.write_str("a string: \"ignore\", \"warn\", or \"error\"")
117            }
118
119            fn visit_str<E>(self, value: &str) -> Result<ArchiveIncludeOnMissing, E>
120            where
121                E: serde::de::Error,
122            {
123                match value {
124                    "ignore" => Ok(ArchiveIncludeOnMissing::Ignore),
125                    "warn" => Ok(ArchiveIncludeOnMissing::Warn),
126                    "error" => Ok(ArchiveIncludeOnMissing::Error),
127                    _ => Err(serde::de::Error::invalid_value(
128                        Unexpected::Str(value),
129                        &self,
130                    )),
131                }
132            }
133        }
134
135        deserializer.deserialize_any(ArchiveIncludeOnMissingVisitor)
136    }
137}
138
139/// Defines the base of the path
140#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
141#[serde(rename_all = "kebab-case")]
142pub(crate) enum ArchiveRelativeTo {
143    /// Path starts at the target directory
144    Target,
145    // TODO: add support for profile relative
146    //TargetProfile,
147}
148
149/// Recursion depth.
150#[derive(Copy, Clone, Debug, Eq, PartialEq)]
151pub enum RecursionDepth {
152    /// A specific depth.
153    Finite(usize),
154
155    /// Infinite recursion.
156    Infinite,
157}
158
159impl RecursionDepth {
160    pub(crate) const ZERO: RecursionDepth = RecursionDepth::Finite(0);
161
162    pub(crate) fn is_zero(self) -> bool {
163        self == Self::ZERO
164    }
165
166    pub(crate) fn decrement(self) -> Self {
167        match self {
168            Self::ZERO => panic!("attempted to decrement zero"),
169            Self::Finite(n) => Self::Finite(n - 1),
170            Self::Infinite => Self::Infinite,
171        }
172    }
173
174    pub(crate) fn unwrap_finite(self) -> usize {
175        match self {
176            Self::Finite(n) => n,
177            Self::Infinite => panic!("expected finite recursion depth"),
178        }
179    }
180}
181
182impl fmt::Display for RecursionDepth {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        match self {
185            Self::Finite(n) => write!(f, "{n}"),
186            Self::Infinite => write!(f, "infinite"),
187        }
188    }
189}
190
191impl<'de> Deserialize<'de> for RecursionDepth {
192    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
193    where
194        D: serde::Deserializer<'de>,
195    {
196        struct RecursionDepthVisitor;
197
198        impl serde::de::Visitor<'_> for RecursionDepthVisitor {
199            type Value = RecursionDepth;
200
201            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
202                formatter.write_str("a non-negative integer or \"infinite\"")
203            }
204
205            // TOML uses i64, not u64
206            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
207            where
208                E: serde::de::Error,
209            {
210                if value < 0 {
211                    return Err(serde::de::Error::invalid_value(
212                        Unexpected::Signed(value),
213                        &self,
214                    ));
215                }
216                Ok(RecursionDepth::Finite(value as usize))
217            }
218
219            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
220            where
221                E: serde::de::Error,
222            {
223                match value {
224                    "infinite" => Ok(RecursionDepth::Infinite),
225                    _ => Err(serde::de::Error::invalid_value(
226                        Unexpected::Str(value),
227                        &self,
228                    )),
229                }
230            }
231        }
232
233        deserializer.deserialize_any(RecursionDepthVisitor)
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::{
241        config::{
242            NextestConfig,
243            test_helpers::{build_platforms, temp_workspace},
244        },
245        errors::ConfigParseErrorKind,
246    };
247    use camino::Utf8Path;
248    use camino_tempfile::tempdir;
249    use config::ConfigError;
250    use indoc::indoc;
251    use nextest_filtering::ParseContext;
252    use test_case::test_case;
253
254    #[test]
255    fn parse_valid() {
256        let config_contents = indoc! {r#"
257            [profile.default.archive]
258            include = [
259                { path = "foo", relative-to = "target" },
260                { path = "bar", relative-to = "target", depth = 1, on-missing = "error" },
261            ]
262
263            [profile.profile1]
264            archive.include = [
265                { path = "baz", relative-to = "target", depth = 0, on-missing = "ignore" },
266            ]
267
268            [profile.profile2]
269            archive.include = []
270
271            [profile.profile3]
272        "#};
273
274        let workspace_dir = tempdir().unwrap();
275
276        let graph = temp_workspace(&workspace_dir, config_contents);
277
278        let pcx = ParseContext::new(&graph);
279
280        let config = NextestConfig::from_sources(
281            graph.workspace().root(),
282            &pcx,
283            None,
284            [],
285            &Default::default(),
286        )
287        .expect("config is valid");
288
289        let default_config = ArchiveConfig {
290            include: vec![
291                ArchiveInclude {
292                    path: "foo".into(),
293                    relative_to: ArchiveRelativeTo::Target,
294                    depth: default_depth(),
295                    on_missing: ArchiveIncludeOnMissing::Warn,
296                },
297                ArchiveInclude {
298                    path: "bar".into(),
299                    relative_to: ArchiveRelativeTo::Target,
300                    depth: TrackDefault::with_deserialized_value(RecursionDepth::Finite(1)),
301                    on_missing: ArchiveIncludeOnMissing::Error,
302                },
303            ],
304        };
305
306        assert_eq!(
307            config
308                .profile("default")
309                .expect("default profile exists")
310                .apply_build_platforms(&build_platforms())
311                .archive_config(),
312            &default_config,
313            "default matches"
314        );
315
316        assert_eq!(
317            config
318                .profile("profile1")
319                .expect("profile exists")
320                .apply_build_platforms(&build_platforms())
321                .archive_config(),
322            &ArchiveConfig {
323                include: vec![ArchiveInclude {
324                    path: "baz".into(),
325                    relative_to: ArchiveRelativeTo::Target,
326                    depth: TrackDefault::with_deserialized_value(RecursionDepth::ZERO),
327                    on_missing: ArchiveIncludeOnMissing::Ignore,
328                }],
329            },
330            "profile1 matches"
331        );
332
333        assert_eq!(
334            config
335                .profile("profile2")
336                .expect("default profile exists")
337                .apply_build_platforms(&build_platforms())
338                .archive_config(),
339            &ArchiveConfig { include: vec![] },
340            "profile2 matches"
341        );
342
343        assert_eq!(
344            config
345                .profile("profile3")
346                .expect("default profile exists")
347                .apply_build_platforms(&build_platforms())
348                .archive_config(),
349            &default_config,
350            "profile3 matches"
351        );
352    }
353
354    #[test_case(
355        indoc!{r#"
356            [profile.default]
357            archive.include = { path = "foo", relative-to = "target" }
358        "#},
359        r"invalid type: map, expected a sequence"
360        ; "missing list")]
361    #[test_case(
362        indoc!{r#"
363            [profile.default]
364            archive.include = [
365                { path = "foo" }
366            ]
367        "#},
368        r"missing field `relative-to`"
369        ; "missing relative-to")]
370    #[test_case(
371        indoc!{r#"
372            [profile.default]
373            archive.include = [
374                { path = "bar", relative-to = "unknown" }
375            ]
376        "#},
377        r"enum ArchiveRelativeTo does not have variant constructor unknown"
378        ; "invalid relative-to")]
379    #[test_case(
380        indoc!{r#"
381            [profile.default]
382            archive.include = [
383                { path = "bar", relative-to = "target", depth = -1 }
384            ]
385        "#},
386        r#"invalid value: integer `-1`, expected a non-negative integer or "infinite""#
387        ; "negative depth")]
388    #[test_case(
389        indoc!{r#"
390            [profile.default]
391            archive.include = [
392                { path = "foo/../bar", relative-to = "target" }
393            ]
394        "#},
395        r#"invalid value: string "foo/../bar", expected a relative path with no parent components"#
396        ; "parent component")]
397    #[test_case(
398        indoc!{r#"
399            [profile.default]
400            archive.include = [
401                { path = "/foo/bar", relative-to = "target" }
402            ]
403        "#},
404        r#"invalid value: string "/foo/bar", expected a relative path with no parent components"#
405        ; "absolute path")]
406    #[test_case(
407        indoc!{r#"
408            [profile.default]
409            archive.include = [
410                { path = "foo", relative-to = "target", on-missing = "unknown" }
411            ]
412        "#},
413        r#"invalid value: string "unknown", expected a string: "ignore", "warn", or "error""#
414        ; "invalid on-missing")]
415    #[test_case(
416        indoc!{r#"
417            [profile.default]
418            archive.include = [
419                { path = "foo", relative-to = "target", on-missing = 42 }
420            ]
421        "#},
422        r#"invalid type: integer `42`, expected a string: "ignore", "warn", or "error""#
423        ; "invalid on-missing type")]
424    fn parse_invalid(config_contents: &str, expected_message: &str) {
425        let workspace_dir = tempdir().unwrap();
426
427        let graph = temp_workspace(&workspace_dir, config_contents);
428
429        let pcx = ParseContext::new(&graph);
430
431        let config_err = NextestConfig::from_sources(
432            graph.workspace().root(),
433            &pcx,
434            None,
435            [],
436            &Default::default(),
437        )
438        .expect_err("config expected to be invalid");
439
440        let message = match config_err.kind() {
441            ConfigParseErrorKind::DeserializeError(path_error) => match path_error.inner() {
442                ConfigError::Message(message) => message,
443                other => {
444                    panic!(
445                        "for config error {config_err:?}, expected ConfigError::Message for inner error {other:?}"
446                    );
447                }
448            },
449            other => {
450                panic!(
451                    "for config error {other:?}, expected ConfigParseErrorKind::DeserializeError"
452                );
453            }
454        };
455
456        assert!(
457            message.contains(expected_message),
458            "expected message: {expected_message}\nactual message: {message}"
459        );
460    }
461
462    #[test]
463    fn test_join_rel_path() {
464        let inputs = [
465            ("a", "b", "a/b"),
466            ("a", "b/c", "a/b/c"),
467            ("a", "", "a"),
468            ("a", ".", "a"),
469        ];
470
471        for (base, rel, expected) in inputs {
472            assert_eq!(
473                join_rel_path(Utf8Path::new(base), Utf8Path::new(rel)),
474                Utf8Path::new(expected),
475                "actual matches expected -- base: {base}, rel: {rel}"
476            );
477        }
478    }
479}