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