1use crate::config::utils::{TrackDefault, deserialize_relative_path};
5use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
6use serde::{Deserialize, de::Unexpected};
7use std::fmt;
8
9#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "kebab-case")]
12pub struct ArchiveConfig {
13 pub include: Vec<ArchiveInclude>,
15}
16
17#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "kebab-case", deny_unknown_fields)]
25pub struct ArchiveInclude {
26 #[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 pub fn depth(&self) -> RecursionDepth {
40 self.depth.value
41 }
42
43 pub fn is_depth_deserialized(&self) -> bool {
45 self.depth.is_deserialized
46 }
47
48 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 pub fn on_missing(&self) -> ArchiveIncludeOnMissing {
57 self.on_missing
58 }
59}
60
61fn default_depth() -> TrackDefault<RecursionDepth> {
62 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum ArchiveIncludeOnMissing {
94 Ignore,
96
97 Warn,
99
100 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#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
140#[serde(rename_all = "kebab-case")]
141pub(crate) enum ArchiveRelativeTo {
142 Target,
144 }
147
148#[derive(Copy, Clone, Debug, Eq, PartialEq)]
150pub enum RecursionDepth {
151 Finite(usize),
153
154 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 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}