1use super::TrackDefault;
5use crate::config::helpers::deserialize_relative_path;
6use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
7use serde::{Deserialize, de::Unexpected};
8use std::fmt;
9
10#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "kebab-case")]
13pub struct ArchiveConfig {
14 pub include: Vec<ArchiveInclude>,
16}
17
18#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "kebab-case", deny_unknown_fields)]
26pub struct ArchiveInclude {
27 #[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 pub fn depth(&self) -> RecursionDepth {
41 self.depth.value
42 }
43
44 pub fn is_depth_deserialized(&self) -> bool {
46 self.depth.is_deserialized
47 }
48
49 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 pub fn on_missing(&self) -> ArchiveIncludeOnMissing {
58 self.on_missing
59 }
60}
61
62fn default_depth() -> TrackDefault<RecursionDepth> {
63 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum ArchiveIncludeOnMissing {
95 Ignore,
97
98 Warn,
100
101 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#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
141#[serde(rename_all = "kebab-case")]
142pub(crate) enum ArchiveRelativeTo {
143 Target,
145 }
148
149#[derive(Copy, Clone, Debug, Eq, PartialEq)]
151pub enum RecursionDepth {
152 Finite(usize),
154
155 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 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}