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#[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 #[cfg_attr(
17 feature = "config-schema",
18 schemars(with = "Option<Vec<ArchiveInclude>>")
20 )]
21 pub include: Vec<ArchiveInclude>,
22}
23
24#[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 #[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 relative_to: ArchiveRelativeTo,
45 #[serde(default = "default_depth")]
47 #[cfg_attr(
48 feature = "config-schema",
49 schemars(schema_with = "RecursionDepth::json_schema")
50 )]
51 depth: TrackDefault<RecursionDepth>,
52 #[serde(default = "default_on_missing")]
54 on_missing: ArchiveIncludeOnMissing,
55}
56
57impl ArchiveInclude {
58 pub fn depth(&self) -> RecursionDepth {
60 self.depth.value
61 }
62
63 pub fn is_depth_deserialized(&self) -> bool {
65 self.depth.is_deserialized
66 }
67
68 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 pub fn on_missing(&self) -> ArchiveIncludeOnMissing {
77 self.on_missing
78 }
79}
80
81fn default_depth() -> TrackDefault<RecursionDepth> {
82 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 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#[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,
118
119 Warn,
121
122 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#[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 Target,
167 }
170
171#[derive(Copy, Clone, Debug, Eq, PartialEq)]
173pub enum RecursionDepth {
174 Finite(usize),
176
177 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 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}