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