Skip to main content

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