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