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 const SCHEMA: &'static str = include_str!("../../jsonschemas/user-config.json");
78
79 pub const DEFAULT_CONFIG: &'static str = include_str!("../../default-user-config.toml");
83
84 pub const REFERENCE_MD: &'static str = include_str!("../../user-config-reference.md");
86
87 pub fn load(location: UserConfigLocation<'_>) -> Result<Self, UserConfigError> {
97 let build_target =
98 Platform::build_target().expect("nextest is built for a supported platform");
99
100 let user_config = CompiledUserConfig::from_location(location)?;
101 let default_user_config = DefaultUserConfig::from_embedded();
102
103 let mut experimental = UserConfigExperimental::from_env();
105 if let Some(config) = &user_config {
106 experimental.extend(config.experimental.iter().copied());
107 }
108
109 let resolved_ui = UiConfig::resolve(
110 &default_user_config.ui,
111 &default_user_config.ui_overrides,
112 user_config.as_ref().map(|c| &c.ui),
113 user_config
114 .as_ref()
115 .map(|c| &c.ui_overrides[..])
116 .unwrap_or(&[]),
117 &build_target,
118 );
119
120 let resolved_record = RecordConfig::resolve(
121 &default_user_config.record,
122 &default_user_config.record_overrides,
123 user_config.as_ref().map(|c| &c.record),
124 user_config
125 .as_ref()
126 .map(|c| &c.record_overrides[..])
127 .unwrap_or(&[]),
128 &build_target,
129 );
130
131 Ok(Self {
132 experimental,
133 ui: resolved_ui,
134 record: resolved_record,
135 })
136 }
137
138 pub fn is_experimental_enabled(&self, feature: UserConfigExperimental) -> bool {
140 self.experimental.contains(&feature)
141 }
142}
143
144trait UserConfigWarnings {
149 fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>);
151}
152
153struct DefaultUserConfigWarnings;
156
157impl UserConfigWarnings for DefaultUserConfigWarnings {
158 fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>) {
159 let mut unknown_str = String::new();
160 if unknown.len() == 1 {
161 unknown_str.push_str("key: ");
163 unknown_str.push_str(unknown.iter().next().unwrap());
164 } else {
165 unknown_str.push_str("keys:\n");
166 for ignored_key in unknown {
167 unknown_str.push('\n');
168 unknown_str.push_str(" - ");
169 unknown_str.push_str(ignored_key);
170 }
171 }
172
173 warn!(
174 "in user config file {}, ignoring unknown configuration {unknown_str}",
175 config_file,
176 );
177 }
178}
179
180#[derive(Clone, Debug, Default, Deserialize)]
189#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
190#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
191#[serde(rename_all = "kebab-case")]
192struct DeserializedUserConfig {
193 #[serde(default)]
195 experimental: ExperimentalConfig,
196
197 #[serde(default)]
199 ui: DeserializedUiConfig,
200
201 #[serde(default)]
203 record: DeserializedRecordConfig,
204
205 #[serde(default)]
208 overrides: Vec<DeserializedOverride>,
209}
210
211#[derive(Clone, Debug, Deserialize)]
213#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
214#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
215#[serde(rename_all = "kebab-case")]
216struct DeserializedOverride {
217 platform: String,
221
222 #[serde(default)]
224 ui: DeserializedUiOverrideData,
225
226 #[serde(default)]
228 record: DeserializedRecordOverrideData,
229}
230
231#[cfg(feature = "config-schema")]
239pub fn user_config_schema() -> schemars::Schema {
240 let mut schema = schemars::schema_for!(DeserializedUserConfig);
241 schema.insert(
243 "x-tombi-toml-version".to_owned(),
244 serde_json::Value::String("v1.1.0".to_owned()),
245 );
246 schema
247}
248
249impl DeserializedUserConfig {
250 fn from_path_with_warnings(
255 path: &Utf8Path,
256 warnings: &mut impl UserConfigWarnings,
257 ) -> Result<Option<Self>, UserConfigError> {
258 debug!("user config: attempting to load from {path}");
259 let contents = match std::fs::read_to_string(path) {
260 Ok(contents) => contents,
261 Err(error) if error.kind() == io::ErrorKind::NotFound => {
262 debug!("user config: file does not exist at {path}");
263 return Ok(None);
264 }
265 Err(error) => {
266 return Err(UserConfigError::Read {
267 path: path.to_owned(),
268 error,
269 });
270 }
271 };
272
273 let (config, unknown) =
274 Self::deserialize_toml(&contents).map_err(|error| UserConfigError::Parse {
275 path: path.to_owned(),
276 error,
277 })?;
278
279 if !unknown.is_empty() {
280 warnings.unknown_config_keys(path, &unknown);
281 }
282
283 debug!("user config: loaded successfully from {path}");
284 Ok(Some(config))
285 }
286
287 fn deserialize_toml(contents: &str) -> Result<(Self, BTreeSet<String>), toml::de::Error> {
289 let deserializer = toml::Deserializer::parse(contents)?;
290 let mut unknown = BTreeSet::new();
291 let config: DeserializedUserConfig = serde_ignored::deserialize(deserializer, |path| {
292 unknown.insert(path.to_string());
293 })?;
294 Ok((config, unknown))
295 }
296
297 fn compile(self, path: &Utf8Path) -> Result<CompiledUserConfig, UserConfigError> {
301 let mut ui_overrides = Vec::with_capacity(self.overrides.len());
302 let mut record_overrides = Vec::with_capacity(self.overrides.len());
303 for (index, override_) in self.overrides.into_iter().enumerate() {
304 let platform_spec = TargetSpec::new(override_.platform).map_err(|error| {
305 UserConfigError::OverridePlatformSpec {
306 path: path.to_owned(),
307 index,
308 error: Box::new(error),
309 }
310 })?;
311 ui_overrides.push(CompiledUiOverride::new(platform_spec.clone(), override_.ui));
314 record_overrides.push(CompiledRecordOverride::new(platform_spec, override_.record));
315 }
316
317 let experimental = self.experimental.to_set();
319
320 Ok(CompiledUserConfig {
321 experimental,
322 ui: self.ui,
323 record: self.record,
324 ui_overrides,
325 record_overrides,
326 })
327 }
328}
329
330#[derive(Clone, Debug)]
335pub(super) struct CompiledUserConfig {
336 pub(super) experimental: BTreeSet<UserConfigExperimental>,
338 pub(super) ui: DeserializedUiConfig,
340 pub(super) record: DeserializedRecordConfig,
342 pub(super) ui_overrides: Vec<CompiledUiOverride>,
344 pub(super) record_overrides: Vec<CompiledRecordOverride>,
346}
347
348impl CompiledUserConfig {
349 pub(super) fn from_location(
351 location: UserConfigLocation<'_>,
352 ) -> Result<Option<Self>, UserConfigError> {
353 Self::from_location_with_warnings(location, &mut DefaultUserConfigWarnings)
354 }
355
356 fn from_location_with_warnings(
359 location: UserConfigLocation<'_>,
360 warnings: &mut impl UserConfigWarnings,
361 ) -> Result<Option<Self>, UserConfigError> {
362 match location {
363 UserConfigLocation::Isolated => {
364 debug!("user config: skipping (isolated)");
365 Ok(None)
366 }
367 UserConfigLocation::Explicit(path) => {
368 debug!("user config: loading from explicit path {path}");
369 match Self::from_path_with_warnings(path, warnings)? {
370 Some(config) => Ok(Some(config)),
371 None => Err(UserConfigError::FileNotFound {
372 path: path.to_owned(),
373 }),
374 }
375 }
376 UserConfigLocation::Default => Self::from_default_location_with_warnings(warnings),
377 }
378 }
379
380 fn from_default_location_with_warnings(
383 warnings: &mut impl UserConfigWarnings,
384 ) -> Result<Option<Self>, UserConfigError> {
385 let paths = user_config_paths()?;
386 if paths.is_empty() {
387 debug!("user config: could not determine config directory");
388 return Ok(None);
389 }
390
391 for path in &paths {
392 match Self::from_path_with_warnings(path, warnings)? {
393 Some(config) => return Ok(Some(config)),
394 None => continue,
395 }
396 }
397
398 debug!(
399 "user config: no config file found at any candidate path: {:?}",
400 paths
401 );
402 Ok(None)
403 }
404
405 fn from_path_with_warnings(
408 path: &Utf8Path,
409 warnings: &mut impl UserConfigWarnings,
410 ) -> Result<Option<Self>, UserConfigError> {
411 match DeserializedUserConfig::from_path_with_warnings(path, warnings)? {
412 Some(config) => Ok(Some(config.compile(path)?)),
413 None => Ok(None),
414 }
415 }
416}
417
418#[derive(Clone, Debug, Deserialize)]
423#[serde(rename_all = "kebab-case")]
424struct DeserializedDefaultUserConfig {
425 ui: DefaultUiConfig,
427
428 record: DefaultRecordConfig,
430
431 #[serde(default)]
433 overrides: Vec<DeserializedOverride>,
434}
435
436#[derive(Clone, Debug)]
441pub(super) struct DefaultUserConfig {
442 pub(super) ui: DefaultUiConfig,
444
445 pub(super) record: DefaultRecordConfig,
447
448 pub(super) ui_overrides: Vec<CompiledUiOverride>,
450
451 pub(super) record_overrides: Vec<CompiledRecordOverride>,
453}
454
455impl DefaultUserConfig {
456 pub(crate) fn from_embedded() -> Self {
461 let deserializer = toml::Deserializer::parse(UserConfig::DEFAULT_CONFIG)
462 .expect("embedded default user config should parse");
463 let mut unknown = BTreeSet::new();
464 let config: DeserializedDefaultUserConfig =
465 serde_ignored::deserialize(deserializer, |path: serde_ignored::Path| {
466 unknown.insert(path.to_string());
467 })
468 .expect("embedded default user config should be valid");
469
470 if !unknown.is_empty() {
473 panic!(
474 "found unknown keys in default user config: {}",
475 unknown.into_iter().collect::<Vec<_>>().join(", ")
476 );
477 }
478
479 let mut ui_overrides = Vec::with_capacity(config.overrides.len());
481 let mut record_overrides = Vec::with_capacity(config.overrides.len());
482 for (index, override_) in config.overrides.into_iter().enumerate() {
483 let platform_spec = TargetSpec::new(override_.platform).unwrap_or_else(|error| {
484 panic!(
485 "embedded default user config has invalid platform spec \
486 in [[overrides]] at index {index}: {error}"
487 )
488 });
489 ui_overrides.push(CompiledUiOverride::new(platform_spec.clone(), override_.ui));
492 record_overrides.push(CompiledRecordOverride::new(platform_spec, override_.record));
493 }
494
495 Self {
496 ui: config.ui,
497 record: config.record,
498 ui_overrides,
499 record_overrides,
500 }
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use camino::Utf8PathBuf;
508 use camino_tempfile::tempdir;
509
510 #[derive(Default)]
512 struct TestUserConfigWarnings {
513 unknown_keys: Option<(Utf8PathBuf, BTreeSet<String>)>,
514 }
515
516 impl UserConfigWarnings for TestUserConfigWarnings {
517 fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>) {
518 self.unknown_keys = Some((config_file.to_owned(), unknown.clone()));
519 }
520 }
521
522 #[test]
523 fn default_user_config_is_valid() {
524 let _ = DefaultUserConfig::from_embedded();
527 }
528
529 #[test]
530 fn ignored_keys() {
531 let config_contents = r#"
532 ignored1 = "test"
533
534 [ui]
535 show-progress = "bar"
536 ignored2 = "hi"
537 "#;
538
539 let temp_dir = tempdir().unwrap();
540 let config_path = temp_dir.path().join("config.toml");
541 std::fs::write(&config_path, config_contents).unwrap();
542
543 let mut warnings = TestUserConfigWarnings::default();
544 let config = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings)
545 .expect("config valid");
546
547 assert!(config.is_some(), "config should be loaded");
548 let config = config.unwrap();
549 assert!(
550 matches!(
551 config.ui.show_progress,
552 Some(crate::user_config::elements::UiShowProgress::Bar)
553 ),
554 "show-progress should be parsed correctly"
555 );
556
557 let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
558 assert_eq!(path, config_path, "path should match");
559 assert_eq!(
560 unknown,
561 maplit::btreeset! {
562 "ignored1".to_owned(),
563 "ui.ignored2".to_owned(),
564 },
565 "unknown keys should be detected"
566 );
567 }
568
569 #[test]
570 fn no_ignored_keys() {
571 let config_contents = r#"
572 [ui]
573 show-progress = "counter"
574 max-progress-running = 10
575 input-handler = false
576 output-indent = true
577 "#;
578
579 let temp_dir = tempdir().unwrap();
580 let config_path = temp_dir.path().join("config.toml");
581 std::fs::write(&config_path, config_contents).unwrap();
582
583 let mut warnings = TestUserConfigWarnings::default();
584 let config = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings)
585 .expect("config valid");
586
587 assert!(config.is_some(), "config should be loaded");
588 assert!(
589 warnings.unknown_keys.is_none(),
590 "no unknown keys should be detected"
591 );
592 }
593
594 #[test]
595 fn overrides_parsing() {
596 let config_contents = r#"
597 [ui]
598 show-progress = "bar"
599
600 [[overrides]]
601 platform = "cfg(windows)"
602 ui.show-progress = "counter"
603 ui.max-progress-running = 4
604
605 [[overrides]]
606 platform = "cfg(unix)"
607 ui.input-handler = false
608 "#;
609
610 let temp_dir = tempdir().unwrap();
611 let config_path = temp_dir.path().join("config.toml");
612 std::fs::write(&config_path, config_contents).unwrap();
613
614 let mut warnings = TestUserConfigWarnings::default();
615 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
616 .expect("config valid")
617 .expect("config should exist");
618
619 assert!(
620 warnings.unknown_keys.is_none(),
621 "no unknown keys should be detected"
622 );
623 assert_eq!(config.ui_overrides.len(), 2, "should have 2 UI overrides");
624 assert_eq!(
625 config.record_overrides.len(),
626 2,
627 "should have 2 record overrides"
628 );
629 }
630
631 #[test]
632 fn overrides_record_parsing() {
633 let config_contents = r#"
634 [record]
635 enabled = false
636
637 [[overrides]]
638 platform = "cfg(unix)"
639 record.enabled = true
640 record.max-output-size = "50MB"
641
642 [[overrides]]
643 platform = "cfg(windows)"
644 record.enabled = true
645 record.max-records = 200
646 "#;
647
648 let temp_dir = tempdir().unwrap();
649 let config_path = temp_dir.path().join("config.toml");
650 std::fs::write(&config_path, config_contents).unwrap();
651
652 let mut warnings = TestUserConfigWarnings::default();
653 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
654 .expect("config valid")
655 .expect("config should exist");
656
657 assert!(
658 warnings.unknown_keys.is_none(),
659 "no unknown keys should be detected"
660 );
661 assert_eq!(
662 config.record_overrides.len(),
663 2,
664 "should have 2 record overrides"
665 );
666 }
667
668 #[test]
669 fn overrides_record_unknown_key() {
670 let config_contents = r#"
671 [[overrides]]
672 platform = "cfg(unix)"
673 record.enabled = true
674 record.unknown-key = "test"
675 "#;
676
677 let temp_dir = tempdir().unwrap();
678 let config_path = temp_dir.path().join("config.toml");
679 std::fs::write(&config_path, config_contents).unwrap();
680
681 let mut warnings = TestUserConfigWarnings::default();
682 let _config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
683 .expect("config valid")
684 .expect("config should exist");
685
686 let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
687 assert_eq!(path, config_path, "path should match");
688 assert!(
689 unknown.contains("overrides.0.record.unknown-key"),
690 "unknown key should be detected: {unknown:?}"
691 );
692 }
693
694 #[test]
695 fn overrides_invalid_platform() {
696 let config_contents = r#"
697 [ui]
698 show-progress = "bar"
699
700 [[overrides]]
701 platform = "invalid platform spec!!!"
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 = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings);
711
712 assert!(
713 matches!(
714 result,
715 Err(UserConfigError::OverridePlatformSpec { index: 0, .. })
716 ),
717 "should fail with platform spec error at index 0"
718 );
719 }
720
721 #[test]
722 fn overrides_missing_platform() {
723 let config_contents = r#"
724 [ui]
725 show-progress = "bar"
726
727 [[overrides]]
728 # platform field is missing - should fail to parse
729 ui.show-progress = "counter"
730 "#;
731
732 let temp_dir = tempdir().unwrap();
733 let config_path = temp_dir.path().join("config.toml");
734 std::fs::write(&config_path, config_contents).unwrap();
735
736 let mut warnings = TestUserConfigWarnings::default();
737 let result = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings);
738
739 assert!(
740 matches!(result, Err(UserConfigError::Parse { .. })),
741 "should fail with parse error due to missing required platform field: {result:?}"
742 );
743 }
744
745 #[test]
746 fn experimental_features_parsing() {
747 let config_contents = r#"
748 [experimental]
749 record = true
750
751 [ui]
752 show-progress = "bar"
753 "#;
754
755 let temp_dir = tempdir().unwrap();
756 let config_path = temp_dir.path().join("config.toml");
757 std::fs::write(&config_path, config_contents).unwrap();
758
759 let mut warnings = TestUserConfigWarnings::default();
760 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
761 .expect("config valid")
762 .expect("config should exist");
763
764 assert!(
765 warnings.unknown_keys.is_none(),
766 "no unknown keys should be detected"
767 );
768 assert!(
769 config
770 .experimental
771 .contains(&UserConfigExperimental::Record),
772 "record feature should be enabled"
773 );
774 }
775
776 #[test]
777 fn experimental_features_disabled() {
778 let config_contents = r#"
779 [experimental]
780 record = false
781
782 [ui]
783 show-progress = "bar"
784 "#;
785
786 let temp_dir = tempdir().unwrap();
787 let config_path = temp_dir.path().join("config.toml");
788 std::fs::write(&config_path, config_contents).unwrap();
789
790 let mut warnings = TestUserConfigWarnings::default();
791 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
792 .expect("config valid")
793 .expect("config should exist");
794
795 assert!(
796 warnings.unknown_keys.is_none(),
797 "no unknown keys should be detected"
798 );
799 assert!(
800 !config
801 .experimental
802 .contains(&UserConfigExperimental::Record),
803 "record feature should not be enabled"
804 );
805 }
806
807 #[test]
808 fn experimental_features_unknown_warning() {
809 let config_contents = r#"
810 [experimental]
811 record = true
812 unknown-feature = true
813
814 [ui]
815 show-progress = "bar"
816 "#;
817
818 let temp_dir = tempdir().unwrap();
819 let config_path = temp_dir.path().join("config.toml");
820 std::fs::write(&config_path, config_contents).unwrap();
821
822 let mut warnings = TestUserConfigWarnings::default();
823 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
824 .expect("config valid")
825 .expect("config should exist");
826
827 let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
829 assert_eq!(path, config_path, "path should match");
830 assert!(
831 unknown.contains("experimental.unknown-feature"),
832 "unknown key should be detected: {unknown:?}"
833 );
834
835 assert!(
837 config
838 .experimental
839 .contains(&UserConfigExperimental::Record),
840 "record feature should be enabled"
841 );
842 }
843}