1use super::{
7 discovery::user_config_paths,
8 elements::{
9 CompiledRecordOverride, CompiledUiOverride, DefaultRecordConfig, DefaultUiConfig,
10 DeserializedRecordConfig, DeserializedRecordOverrideData, DeserializedUiConfig,
11 DeserializedUiOverrideData, RecordConfig, UiConfig,
12 },
13 experimental::{ExperimentalConfig, UserConfigExperimental},
14};
15use crate::errors::UserConfigError;
16use camino::Utf8Path;
17use serde::Deserialize;
18use std::{collections::BTreeSet, io};
19use target_spec::{Platform, TargetSpec};
20use tracing::{debug, warn};
21
22pub const USER_CONFIG_NONE: &str = "none";
25
26#[derive(Clone, Copy, Debug)]
28pub enum UserConfigLocation<'a> {
29 Default,
32
33 Isolated,
37
38 Explicit(&'a Utf8Path),
42}
43
44impl<'a> UserConfigLocation<'a> {
45 pub fn from_cli_or_env(s: Option<&'a str>) -> Self {
50 match s {
51 None => Self::Default,
52 Some(s) if s == USER_CONFIG_NONE => Self::Isolated,
53 Some(s) => Self::Explicit(Utf8Path::new(s)),
54 }
55 }
56}
57
58#[derive(Clone, Debug)]
60pub struct UserConfig {
61 pub experimental: BTreeSet<UserConfigExperimental>,
63 pub ui: UiConfig,
65 pub record: RecordConfig,
67}
68
69impl UserConfig {
70 pub fn load(location: UserConfigLocation<'_>) -> Result<Self, UserConfigError> {
80 let build_target =
81 Platform::build_target().expect("nextest is built for a supported platform");
82
83 let user_config = CompiledUserConfig::from_location(location)?;
84 let default_user_config = DefaultUserConfig::from_embedded();
85
86 let mut experimental = UserConfigExperimental::from_env();
88 if let Some(config) = &user_config {
89 experimental.extend(config.experimental.iter().copied());
90 }
91
92 let resolved_ui = UiConfig::resolve(
93 &default_user_config.ui,
94 &default_user_config.ui_overrides,
95 user_config.as_ref().map(|c| &c.ui),
96 user_config
97 .as_ref()
98 .map(|c| &c.ui_overrides[..])
99 .unwrap_or(&[]),
100 &build_target,
101 );
102
103 let resolved_record = RecordConfig::resolve(
104 &default_user_config.record,
105 &default_user_config.record_overrides,
106 user_config.as_ref().map(|c| &c.record),
107 user_config
108 .as_ref()
109 .map(|c| &c.record_overrides[..])
110 .unwrap_or(&[]),
111 &build_target,
112 );
113
114 Ok(Self {
115 experimental,
116 ui: resolved_ui,
117 record: resolved_record,
118 })
119 }
120
121 pub fn is_experimental_enabled(&self, feature: UserConfigExperimental) -> bool {
123 self.experimental.contains(&feature)
124 }
125}
126
127trait UserConfigWarnings {
132 fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>);
134}
135
136struct DefaultUserConfigWarnings;
139
140impl UserConfigWarnings for DefaultUserConfigWarnings {
141 fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>) {
142 let mut unknown_str = String::new();
143 if unknown.len() == 1 {
144 unknown_str.push_str("key: ");
146 unknown_str.push_str(unknown.iter().next().unwrap());
147 } else {
148 unknown_str.push_str("keys:\n");
149 for ignored_key in unknown {
150 unknown_str.push('\n');
151 unknown_str.push_str(" - ");
152 unknown_str.push_str(ignored_key);
153 }
154 }
155
156 warn!(
157 "in user config file {}, ignoring unknown configuration {unknown_str}",
158 config_file,
159 );
160 }
161}
162
163#[derive(Clone, Debug, Default, Deserialize)]
171#[serde(rename_all = "kebab-case")]
172struct DeserializedUserConfig {
173 #[serde(default)]
182 experimental: ExperimentalConfig,
183
184 #[serde(default)]
186 ui: DeserializedUiConfig,
187
188 #[serde(default)]
190 record: DeserializedRecordConfig,
191
192 #[serde(default)]
194 overrides: Vec<DeserializedOverride>,
195}
196
197#[derive(Clone, Debug, Deserialize)]
202#[serde(rename_all = "kebab-case")]
203struct DeserializedOverride {
204 platform: String,
209
210 #[serde(default)]
212 ui: DeserializedUiOverrideData,
213
214 #[serde(default)]
216 record: DeserializedRecordOverrideData,
217}
218
219impl DeserializedUserConfig {
220 fn from_path_with_warnings(
225 path: &Utf8Path,
226 warnings: &mut impl UserConfigWarnings,
227 ) -> Result<Option<Self>, UserConfigError> {
228 debug!("user config: attempting to load from {path}");
229 let contents = match std::fs::read_to_string(path) {
230 Ok(contents) => contents,
231 Err(error) if error.kind() == io::ErrorKind::NotFound => {
232 debug!("user config: file does not exist at {path}");
233 return Ok(None);
234 }
235 Err(error) => {
236 return Err(UserConfigError::Read {
237 path: path.to_owned(),
238 error,
239 });
240 }
241 };
242
243 let (config, unknown) =
244 Self::deserialize_toml(&contents).map_err(|error| UserConfigError::Parse {
245 path: path.to_owned(),
246 error,
247 })?;
248
249 if !unknown.is_empty() {
250 warnings.unknown_config_keys(path, &unknown);
251 }
252
253 debug!("user config: loaded successfully from {path}");
254 Ok(Some(config))
255 }
256
257 fn deserialize_toml(contents: &str) -> Result<(Self, BTreeSet<String>), toml::de::Error> {
259 let deserializer = toml::Deserializer::parse(contents)?;
260 let mut unknown = BTreeSet::new();
261 let config: DeserializedUserConfig = serde_ignored::deserialize(deserializer, |path| {
262 unknown.insert(path.to_string());
263 })?;
264 Ok((config, unknown))
265 }
266
267 fn compile(self, path: &Utf8Path) -> Result<CompiledUserConfig, UserConfigError> {
271 let mut ui_overrides = Vec::with_capacity(self.overrides.len());
272 let mut record_overrides = Vec::with_capacity(self.overrides.len());
273 for (index, override_) in self.overrides.into_iter().enumerate() {
274 let platform_spec = TargetSpec::new(override_.platform).map_err(|error| {
275 UserConfigError::OverridePlatformSpec {
276 path: path.to_owned(),
277 index,
278 error: Box::new(error),
279 }
280 })?;
281 ui_overrides.push(CompiledUiOverride::new(platform_spec.clone(), override_.ui));
284 record_overrides.push(CompiledRecordOverride::new(platform_spec, override_.record));
285 }
286
287 let experimental = self.experimental.to_set();
289
290 Ok(CompiledUserConfig {
291 experimental,
292 ui: self.ui,
293 record: self.record,
294 ui_overrides,
295 record_overrides,
296 })
297 }
298}
299
300#[derive(Clone, Debug)]
305pub(super) struct CompiledUserConfig {
306 pub(super) experimental: BTreeSet<UserConfigExperimental>,
308 pub(super) ui: DeserializedUiConfig,
310 pub(super) record: DeserializedRecordConfig,
312 pub(super) ui_overrides: Vec<CompiledUiOverride>,
314 pub(super) record_overrides: Vec<CompiledRecordOverride>,
316}
317
318impl CompiledUserConfig {
319 pub(super) fn from_location(
321 location: UserConfigLocation<'_>,
322 ) -> Result<Option<Self>, UserConfigError> {
323 Self::from_location_with_warnings(location, &mut DefaultUserConfigWarnings)
324 }
325
326 fn from_location_with_warnings(
329 location: UserConfigLocation<'_>,
330 warnings: &mut impl UserConfigWarnings,
331 ) -> Result<Option<Self>, UserConfigError> {
332 match location {
333 UserConfigLocation::Isolated => {
334 debug!("user config: skipping (isolated)");
335 Ok(None)
336 }
337 UserConfigLocation::Explicit(path) => {
338 debug!("user config: loading from explicit path {path}");
339 match Self::from_path_with_warnings(path, warnings)? {
340 Some(config) => Ok(Some(config)),
341 None => Err(UserConfigError::FileNotFound {
342 path: path.to_owned(),
343 }),
344 }
345 }
346 UserConfigLocation::Default => Self::from_default_location_with_warnings(warnings),
347 }
348 }
349
350 fn from_default_location_with_warnings(
353 warnings: &mut impl UserConfigWarnings,
354 ) -> Result<Option<Self>, UserConfigError> {
355 let paths = user_config_paths()?;
356 if paths.is_empty() {
357 debug!("user config: could not determine config directory");
358 return Ok(None);
359 }
360
361 for path in &paths {
362 match Self::from_path_with_warnings(path, warnings)? {
363 Some(config) => return Ok(Some(config)),
364 None => continue,
365 }
366 }
367
368 debug!(
369 "user config: no config file found at any candidate path: {:?}",
370 paths
371 );
372 Ok(None)
373 }
374
375 fn from_path_with_warnings(
378 path: &Utf8Path,
379 warnings: &mut impl UserConfigWarnings,
380 ) -> Result<Option<Self>, UserConfigError> {
381 match DeserializedUserConfig::from_path_with_warnings(path, warnings)? {
382 Some(config) => Ok(Some(config.compile(path)?)),
383 None => Ok(None),
384 }
385 }
386}
387
388#[derive(Clone, Debug, Deserialize)]
393#[serde(rename_all = "kebab-case")]
394struct DeserializedDefaultUserConfig {
395 ui: DefaultUiConfig,
397
398 record: DefaultRecordConfig,
400
401 #[serde(default)]
403 overrides: Vec<DeserializedOverride>,
404}
405
406#[derive(Clone, Debug)]
411pub(super) struct DefaultUserConfig {
412 pub(super) ui: DefaultUiConfig,
414
415 pub(super) record: DefaultRecordConfig,
417
418 pub(super) ui_overrides: Vec<CompiledUiOverride>,
420
421 pub(super) record_overrides: Vec<CompiledRecordOverride>,
423}
424
425impl DefaultUserConfig {
426 const DEFAULT_CONFIG: &'static str = include_str!("../../default-user-config.toml");
428
429 pub(crate) fn from_embedded() -> Self {
434 let deserializer = toml::Deserializer::parse(Self::DEFAULT_CONFIG)
435 .expect("embedded default user config should parse");
436 let mut unknown = BTreeSet::new();
437 let config: DeserializedDefaultUserConfig =
438 serde_ignored::deserialize(deserializer, |path: serde_ignored::Path| {
439 unknown.insert(path.to_string());
440 })
441 .expect("embedded default user config should be valid");
442
443 if !unknown.is_empty() {
446 panic!(
447 "found unknown keys in default user config: {}",
448 unknown.into_iter().collect::<Vec<_>>().join(", ")
449 );
450 }
451
452 let mut ui_overrides = Vec::with_capacity(config.overrides.len());
454 let mut record_overrides = Vec::with_capacity(config.overrides.len());
455 for (index, override_) in config.overrides.into_iter().enumerate() {
456 let platform_spec = TargetSpec::new(override_.platform).unwrap_or_else(|error| {
457 panic!(
458 "embedded default user config has invalid platform spec \
459 in [[overrides]] at index {index}: {error}"
460 )
461 });
462 ui_overrides.push(CompiledUiOverride::new(platform_spec.clone(), override_.ui));
465 record_overrides.push(CompiledRecordOverride::new(platform_spec, override_.record));
466 }
467
468 Self {
469 ui: config.ui,
470 record: config.record,
471 ui_overrides,
472 record_overrides,
473 }
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use camino::Utf8PathBuf;
481 use camino_tempfile::tempdir;
482
483 #[derive(Default)]
485 struct TestUserConfigWarnings {
486 unknown_keys: Option<(Utf8PathBuf, BTreeSet<String>)>,
487 }
488
489 impl UserConfigWarnings for TestUserConfigWarnings {
490 fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>) {
491 self.unknown_keys = Some((config_file.to_owned(), unknown.clone()));
492 }
493 }
494
495 #[test]
496 fn default_user_config_is_valid() {
497 let _ = DefaultUserConfig::from_embedded();
500 }
501
502 #[test]
503 fn ignored_keys() {
504 let config_contents = r#"
505 ignored1 = "test"
506
507 [ui]
508 show-progress = "bar"
509 ignored2 = "hi"
510 "#;
511
512 let temp_dir = tempdir().unwrap();
513 let config_path = temp_dir.path().join("config.toml");
514 std::fs::write(&config_path, config_contents).unwrap();
515
516 let mut warnings = TestUserConfigWarnings::default();
517 let config = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings)
518 .expect("config valid");
519
520 assert!(config.is_some(), "config should be loaded");
521 let config = config.unwrap();
522 assert!(
523 matches!(
524 config.ui.show_progress,
525 Some(crate::user_config::elements::UiShowProgress::Bar)
526 ),
527 "show-progress should be parsed correctly"
528 );
529
530 let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
531 assert_eq!(path, config_path, "path should match");
532 assert_eq!(
533 unknown,
534 maplit::btreeset! {
535 "ignored1".to_owned(),
536 "ui.ignored2".to_owned(),
537 },
538 "unknown keys should be detected"
539 );
540 }
541
542 #[test]
543 fn no_ignored_keys() {
544 let config_contents = r#"
545 [ui]
546 show-progress = "counter"
547 max-progress-running = 10
548 input-handler = false
549 output-indent = true
550 "#;
551
552 let temp_dir = tempdir().unwrap();
553 let config_path = temp_dir.path().join("config.toml");
554 std::fs::write(&config_path, config_contents).unwrap();
555
556 let mut warnings = TestUserConfigWarnings::default();
557 let config = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings)
558 .expect("config valid");
559
560 assert!(config.is_some(), "config should be loaded");
561 assert!(
562 warnings.unknown_keys.is_none(),
563 "no unknown keys should be detected"
564 );
565 }
566
567 #[test]
568 fn overrides_parsing() {
569 let config_contents = r#"
570 [ui]
571 show-progress = "bar"
572
573 [[overrides]]
574 platform = "cfg(windows)"
575 ui.show-progress = "counter"
576 ui.max-progress-running = 4
577
578 [[overrides]]
579 platform = "cfg(unix)"
580 ui.input-handler = false
581 "#;
582
583 let temp_dir = tempdir().unwrap();
584 let config_path = temp_dir.path().join("config.toml");
585 std::fs::write(&config_path, config_contents).unwrap();
586
587 let mut warnings = TestUserConfigWarnings::default();
588 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
589 .expect("config valid")
590 .expect("config should exist");
591
592 assert!(
593 warnings.unknown_keys.is_none(),
594 "no unknown keys should be detected"
595 );
596 assert_eq!(config.ui_overrides.len(), 2, "should have 2 UI overrides");
597 assert_eq!(
598 config.record_overrides.len(),
599 2,
600 "should have 2 record overrides"
601 );
602 }
603
604 #[test]
605 fn overrides_record_parsing() {
606 let config_contents = r#"
607 [record]
608 enabled = false
609
610 [[overrides]]
611 platform = "cfg(unix)"
612 record.enabled = true
613 record.max-output-size = "50MB"
614
615 [[overrides]]
616 platform = "cfg(windows)"
617 record.enabled = true
618 record.max-records = 200
619 "#;
620
621 let temp_dir = tempdir().unwrap();
622 let config_path = temp_dir.path().join("config.toml");
623 std::fs::write(&config_path, config_contents).unwrap();
624
625 let mut warnings = TestUserConfigWarnings::default();
626 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
627 .expect("config valid")
628 .expect("config should exist");
629
630 assert!(
631 warnings.unknown_keys.is_none(),
632 "no unknown keys should be detected"
633 );
634 assert_eq!(
635 config.record_overrides.len(),
636 2,
637 "should have 2 record overrides"
638 );
639 }
640
641 #[test]
642 fn overrides_record_unknown_key() {
643 let config_contents = r#"
644 [[overrides]]
645 platform = "cfg(unix)"
646 record.enabled = true
647 record.unknown-key = "test"
648 "#;
649
650 let temp_dir = tempdir().unwrap();
651 let config_path = temp_dir.path().join("config.toml");
652 std::fs::write(&config_path, config_contents).unwrap();
653
654 let mut warnings = TestUserConfigWarnings::default();
655 let _config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
656 .expect("config valid")
657 .expect("config should exist");
658
659 let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
660 assert_eq!(path, config_path, "path should match");
661 assert!(
662 unknown.contains("overrides.0.record.unknown-key"),
663 "unknown key should be detected: {unknown:?}"
664 );
665 }
666
667 #[test]
668 fn overrides_invalid_platform() {
669 let config_contents = r#"
670 [ui]
671 show-progress = "bar"
672
673 [[overrides]]
674 platform = "invalid platform spec!!!"
675 ui.show-progress = "counter"
676 "#;
677
678 let temp_dir = tempdir().unwrap();
679 let config_path = temp_dir.path().join("config.toml");
680 std::fs::write(&config_path, config_contents).unwrap();
681
682 let mut warnings = TestUserConfigWarnings::default();
683 let result = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings);
684
685 assert!(
686 matches!(
687 result,
688 Err(UserConfigError::OverridePlatformSpec { index: 0, .. })
689 ),
690 "should fail with platform spec error at index 0"
691 );
692 }
693
694 #[test]
695 fn overrides_missing_platform() {
696 let config_contents = r#"
697 [ui]
698 show-progress = "bar"
699
700 [[overrides]]
701 # platform field is missing - should fail to parse
702 ui.show-progress = "counter"
703 "#;
704
705 let temp_dir = tempdir().unwrap();
706 let config_path = temp_dir.path().join("config.toml");
707 std::fs::write(&config_path, config_contents).unwrap();
708
709 let mut warnings = TestUserConfigWarnings::default();
710 let result = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings);
711
712 assert!(
713 matches!(result, Err(UserConfigError::Parse { .. })),
714 "should fail with parse error due to missing required platform field: {result:?}"
715 );
716 }
717
718 #[test]
719 fn experimental_features_parsing() {
720 let config_contents = r#"
721 [experimental]
722 record = true
723
724 [ui]
725 show-progress = "bar"
726 "#;
727
728 let temp_dir = tempdir().unwrap();
729 let config_path = temp_dir.path().join("config.toml");
730 std::fs::write(&config_path, config_contents).unwrap();
731
732 let mut warnings = TestUserConfigWarnings::default();
733 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
734 .expect("config valid")
735 .expect("config should exist");
736
737 assert!(
738 warnings.unknown_keys.is_none(),
739 "no unknown keys should be detected"
740 );
741 assert!(
742 config
743 .experimental
744 .contains(&UserConfigExperimental::Record),
745 "record feature should be enabled"
746 );
747 }
748
749 #[test]
750 fn experimental_features_disabled() {
751 let config_contents = r#"
752 [experimental]
753 record = false
754
755 [ui]
756 show-progress = "bar"
757 "#;
758
759 let temp_dir = tempdir().unwrap();
760 let config_path = temp_dir.path().join("config.toml");
761 std::fs::write(&config_path, config_contents).unwrap();
762
763 let mut warnings = TestUserConfigWarnings::default();
764 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
765 .expect("config valid")
766 .expect("config should exist");
767
768 assert!(
769 warnings.unknown_keys.is_none(),
770 "no unknown keys should be detected"
771 );
772 assert!(
773 !config
774 .experimental
775 .contains(&UserConfigExperimental::Record),
776 "record feature should not be enabled"
777 );
778 }
779
780 #[test]
781 fn experimental_features_unknown_warning() {
782 let config_contents = r#"
783 [experimental]
784 record = true
785 unknown-feature = true
786
787 [ui]
788 show-progress = "bar"
789 "#;
790
791 let temp_dir = tempdir().unwrap();
792 let config_path = temp_dir.path().join("config.toml");
793 std::fs::write(&config_path, config_contents).unwrap();
794
795 let mut warnings = TestUserConfigWarnings::default();
796 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
797 .expect("config valid")
798 .expect("config should exist");
799
800 let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
802 assert_eq!(path, config_path, "path should match");
803 assert!(
804 unknown.contains("experimental.unknown-feature"),
805 "unknown key should be detected: {unknown:?}"
806 );
807
808 assert!(
810 config
811 .experimental
812 .contains(&UserConfigExperimental::Record),
813 "record feature should be enabled"
814 );
815 }
816}