nextest_runner/config/elements/
archive.rs

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