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 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}