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        r"invalid type: map, expected a sequence"
356        ; "missing list")]
357    #[test_case(
358        indoc!{r#"
359            [profile.default]
360            archive.include = [
361                { path = "foo" }
362            ]
363        "#},
364        r"missing field `relative-to`"
365        ; "missing relative-to")]
366    #[test_case(
367        indoc!{r#"
368            [profile.default]
369            archive.include = [
370                { path = "bar", relative-to = "unknown" }
371            ]
372        "#},
373        r"enum ArchiveRelativeTo does not have variant constructor unknown"
374        ; "invalid relative-to")]
375    #[test_case(
376        indoc!{r#"
377            [profile.default]
378            archive.include = [
379                { path = "bar", relative-to = "target", depth = -1 }
380            ]
381        "#},
382        r#"invalid value: integer `-1`, expected a non-negative integer or "infinite""#
383        ; "negative depth")]
384    #[test_case(
385        indoc!{r#"
386            [profile.default]
387            archive.include = [
388                { path = "foo/../bar", relative-to = "target" }
389            ]
390        "#},
391        r#"invalid value: string "foo/../bar", expected a relative path with no parent components"#
392        ; "parent component")]
393    #[test_case(
394        indoc!{r#"
395            [profile.default]
396            archive.include = [
397                { path = "/foo/bar", relative-to = "target" }
398            ]
399        "#},
400        r#"invalid value: string "/foo/bar", expected a relative path with no parent components"#
401        ; "absolute path")]
402    #[test_case(
403        indoc!{r#"
404            [profile.default]
405            archive.include = [
406                { path = "foo", relative-to = "target", on-missing = "unknown" }
407            ]
408        "#},
409        r#"invalid value: string "unknown", expected a string: "ignore", "warn", or "error""#
410        ; "invalid on-missing")]
411    #[test_case(
412        indoc!{r#"
413            [profile.default]
414            archive.include = [
415                { path = "foo", relative-to = "target", on-missing = 42 }
416            ]
417        "#},
418        r#"invalid type: integer `42`, expected a string: "ignore", "warn", or "error""#
419        ; "invalid on-missing type")]
420    fn parse_invalid(config_contents: &str, expected_message: &str) {
421        let workspace_dir = tempdir().unwrap();
422
423        let graph = temp_workspace(&workspace_dir, config_contents);
424
425        let pcx = ParseContext::new(&graph);
426
427        let config_err = NextestConfig::from_sources(
428            graph.workspace().root(),
429            &pcx,
430            None,
431            [],
432            &Default::default(),
433        )
434        .expect_err("config expected to be invalid");
435
436        let message = match config_err.kind() {
437            ConfigParseErrorKind::DeserializeError(path_error) => match path_error.inner() {
438                ConfigError::Message(message) => message,
439                other => {
440                    panic!(
441                        "for config error {config_err:?}, expected ConfigError::Message for inner error {other:?}"
442                    );
443                }
444            },
445            other => {
446                panic!(
447                    "for config error {other:?}, expected ConfigParseErrorKind::DeserializeError"
448                );
449            }
450        };
451
452        assert!(
453            message.contains(expected_message),
454            "expected message: {expected_message}\nactual message: {message}"
455        );
456    }
457
458    #[test]
459    fn test_join_rel_path() {
460        let inputs = [
461            ("a", "b", "a/b"),
462            ("a", "b/c", "a/b/c"),
463            ("a", "", "a"),
464            ("a", ".", "a"),
465        ];
466
467        for (base, rel, expected) in inputs {
468            assert_eq!(
469                join_rel_path(Utf8Path::new(base), Utf8Path::new(rel)),
470                Utf8Path::new(expected),
471                "actual matches expected -- base: {base}, rel: {rel}"
472            );
473        }
474    }
475}