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)]
192 experimental: ExperimentalConfig,
193
194 #[serde(default)]
196 ui: DeserializedUiConfig,
197
198 #[serde(default)]
200 record: DeserializedRecordConfig,
201
202 #[serde(default)]
205 overrides: Vec<DeserializedOverride>,
206}
207
208#[derive(Clone, Debug, Deserialize)]
210#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
211#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
212#[serde(rename_all = "kebab-case")]
213struct DeserializedOverride {
214 platform: String,
218
219 #[serde(default)]
221 ui: DeserializedUiOverrideData,
222
223 #[serde(default)]
225 record: DeserializedRecordOverrideData,
226}
227
228#[cfg(feature = "config-schema")]
236pub fn user_config_schema() -> schemars::Schema {
237 let mut schema = schemars::schema_for!(DeserializedUserConfig);
238 schema.insert(
240 "x-tombi-toml-version".to_owned(),
241 serde_json::Value::String("v1.1.0".to_owned()),
242 );
243 schema
244}
245
246impl DeserializedUserConfig {
247 fn from_path_with_warnings(
252 path: &Utf8Path,
253 warnings: &mut impl UserConfigWarnings,
254 ) -> Result<Option<Self>, UserConfigError> {
255 debug!("user config: attempting to load from {path}");
256 let contents = match std::fs::read_to_string(path) {
257 Ok(contents) => contents,
258 Err(error) if error.kind() == io::ErrorKind::NotFound => {
259 debug!("user config: file does not exist at {path}");
260 return Ok(None);
261 }
262 Err(error) => {
263 return Err(UserConfigError::Read {
264 path: path.to_owned(),
265 error,
266 });
267 }
268 };
269
270 let (config, unknown) =
271 Self::deserialize_toml(&contents).map_err(|error| UserConfigError::Parse {
272 path: path.to_owned(),
273 error,
274 })?;
275
276 if !unknown.is_empty() {
277 warnings.unknown_config_keys(path, &unknown);
278 }
279
280 debug!("user config: loaded successfully from {path}");
281 Ok(Some(config))
282 }
283
284 fn deserialize_toml(contents: &str) -> Result<(Self, BTreeSet<String>), toml::de::Error> {
286 let deserializer = toml::Deserializer::parse(contents)?;
287 let mut unknown = BTreeSet::new();
288 let config: DeserializedUserConfig = serde_ignored::deserialize(deserializer, |path| {
289 unknown.insert(path.to_string());
290 })?;
291 Ok((config, unknown))
292 }
293
294 fn compile(self, path: &Utf8Path) -> Result<CompiledUserConfig, UserConfigError> {
298 let mut ui_overrides = Vec::with_capacity(self.overrides.len());
299 let mut record_overrides = Vec::with_capacity(self.overrides.len());
300 for (index, override_) in self.overrides.into_iter().enumerate() {
301 let platform_spec = TargetSpec::new(override_.platform).map_err(|error| {
302 UserConfigError::OverridePlatformSpec {
303 path: path.to_owned(),
304 index,
305 error: Box::new(error),
306 }
307 })?;
308 ui_overrides.push(CompiledUiOverride::new(platform_spec.clone(), override_.ui));
311 record_overrides.push(CompiledRecordOverride::new(platform_spec, override_.record));
312 }
313
314 let experimental = self.experimental.to_set();
316
317 Ok(CompiledUserConfig {
318 experimental,
319 ui: self.ui,
320 record: self.record,
321 ui_overrides,
322 record_overrides,
323 })
324 }
325}
326
327#[derive(Clone, Debug)]
332pub(super) struct CompiledUserConfig {
333 pub(super) experimental: BTreeSet<UserConfigExperimental>,
335 pub(super) ui: DeserializedUiConfig,
337 pub(super) record: DeserializedRecordConfig,
339 pub(super) ui_overrides: Vec<CompiledUiOverride>,
341 pub(super) record_overrides: Vec<CompiledRecordOverride>,
343}
344
345impl CompiledUserConfig {
346 pub(super) fn from_location(
348 location: UserConfigLocation<'_>,
349 ) -> Result<Option<Self>, UserConfigError> {
350 Self::from_location_with_warnings(location, &mut DefaultUserConfigWarnings)
351 }
352
353 fn from_location_with_warnings(
356 location: UserConfigLocation<'_>,
357 warnings: &mut impl UserConfigWarnings,
358 ) -> Result<Option<Self>, UserConfigError> {
359 match location {
360 UserConfigLocation::Isolated => {
361 debug!("user config: skipping (isolated)");
362 Ok(None)
363 }
364 UserConfigLocation::Explicit(path) => {
365 debug!("user config: loading from explicit path {path}");
366 match Self::from_path_with_warnings(path, warnings)? {
367 Some(config) => Ok(Some(config)),
368 None => Err(UserConfigError::FileNotFound {
369 path: path.to_owned(),
370 }),
371 }
372 }
373 UserConfigLocation::Default => Self::from_default_location_with_warnings(warnings),
374 }
375 }
376
377 fn from_default_location_with_warnings(
380 warnings: &mut impl UserConfigWarnings,
381 ) -> Result<Option<Self>, UserConfigError> {
382 let paths = user_config_paths()?;
383 if paths.is_empty() {
384 debug!("user config: could not determine config directory");
385 return Ok(None);
386 }
387
388 for path in &paths {
389 match Self::from_path_with_warnings(path, warnings)? {
390 Some(config) => return Ok(Some(config)),
391 None => continue,
392 }
393 }
394
395 debug!(
396 "user config: no config file found at any candidate path: {:?}",
397 paths
398 );
399 Ok(None)
400 }
401
402 fn from_path_with_warnings(
405 path: &Utf8Path,
406 warnings: &mut impl UserConfigWarnings,
407 ) -> Result<Option<Self>, UserConfigError> {
408 match DeserializedUserConfig::from_path_with_warnings(path, warnings)? {
409 Some(config) => Ok(Some(config.compile(path)?)),
410 None => Ok(None),
411 }
412 }
413}
414
415#[derive(Clone, Debug, Deserialize)]
420#[serde(rename_all = "kebab-case")]
421struct DeserializedDefaultUserConfig {
422 ui: DefaultUiConfig,
424
425 record: DefaultRecordConfig,
427
428 #[serde(default)]
430 overrides: Vec<DeserializedOverride>,
431}
432
433#[derive(Clone, Debug)]
438pub(super) struct DefaultUserConfig {
439 pub(super) ui: DefaultUiConfig,
441
442 pub(super) record: DefaultRecordConfig,
444
445 pub(super) ui_overrides: Vec<CompiledUiOverride>,
447
448 pub(super) record_overrides: Vec<CompiledRecordOverride>,
450}
451
452impl DefaultUserConfig {
453 pub(crate) fn from_embedded() -> Self {
458 let deserializer = toml::Deserializer::parse(UserConfig::DEFAULT_CONFIG)
459 .expect("embedded default user config should parse");
460 let mut unknown = BTreeSet::new();
461 let config: DeserializedDefaultUserConfig =
462 serde_ignored::deserialize(deserializer, |path: serde_ignored::Path| {
463 unknown.insert(path.to_string());
464 })
465 .expect("embedded default user config should be valid");
466
467 if !unknown.is_empty() {
470 panic!(
471 "found unknown keys in default user config: {}",
472 unknown.into_iter().collect::<Vec<_>>().join(", ")
473 );
474 }
475
476 let mut ui_overrides = Vec::with_capacity(config.overrides.len());
478 let mut record_overrides = Vec::with_capacity(config.overrides.len());
479 for (index, override_) in config.overrides.into_iter().enumerate() {
480 let platform_spec = TargetSpec::new(override_.platform).unwrap_or_else(|error| {
481 panic!(
482 "embedded default user config has invalid platform spec \
483 in [[overrides]] at index {index}: {error}"
484 )
485 });
486 ui_overrides.push(CompiledUiOverride::new(platform_spec.clone(), override_.ui));
489 record_overrides.push(CompiledRecordOverride::new(platform_spec, override_.record));
490 }
491
492 Self {
493 ui: config.ui,
494 record: config.record,
495 ui_overrides,
496 record_overrides,
497 }
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use camino::Utf8PathBuf;
505 use camino_tempfile::tempdir;
506
507 #[derive(Default)]
509 struct TestUserConfigWarnings {
510 unknown_keys: Option<(Utf8PathBuf, BTreeSet<String>)>,
511 }
512
513 impl UserConfigWarnings for TestUserConfigWarnings {
514 fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>) {
515 self.unknown_keys = Some((config_file.to_owned(), unknown.clone()));
516 }
517 }
518
519 #[test]
520 fn default_user_config_is_valid() {
521 let _ = DefaultUserConfig::from_embedded();
524 }
525
526 #[test]
527 fn ignored_keys() {
528 let config_contents = r#"
529 ignored1 = "test"
530
531 [ui]
532 show-progress = "bar"
533 ignored2 = "hi"
534 "#;
535
536 let temp_dir = tempdir().unwrap();
537 let config_path = temp_dir.path().join("config.toml");
538 std::fs::write(&config_path, config_contents).unwrap();
539
540 let mut warnings = TestUserConfigWarnings::default();
541 let config = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings)
542 .expect("config valid");
543
544 assert!(config.is_some(), "config should be loaded");
545 let config = config.unwrap();
546 assert!(
547 matches!(
548 config.ui.show_progress,
549 Some(crate::user_config::elements::UiShowProgress::Bar)
550 ),
551 "show-progress should be parsed correctly"
552 );
553
554 let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
555 assert_eq!(path, config_path, "path should match");
556 assert_eq!(
557 unknown,
558 maplit::btreeset! {
559 "ignored1".to_owned(),
560 "ui.ignored2".to_owned(),
561 },
562 "unknown keys should be detected"
563 );
564 }
565
566 #[test]
567 fn no_ignored_keys() {
568 let config_contents = r#"
569 [ui]
570 show-progress = "counter"
571 max-progress-running = 10
572 input-handler = false
573 output-indent = true
574 "#;
575
576 let temp_dir = tempdir().unwrap();
577 let config_path = temp_dir.path().join("config.toml");
578 std::fs::write(&config_path, config_contents).unwrap();
579
580 let mut warnings = TestUserConfigWarnings::default();
581 let config = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings)
582 .expect("config valid");
583
584 assert!(config.is_some(), "config should be loaded");
585 assert!(
586 warnings.unknown_keys.is_none(),
587 "no unknown keys should be detected"
588 );
589 }
590
591 #[test]
592 fn overrides_parsing() {
593 let config_contents = r#"
594 [ui]
595 show-progress = "bar"
596
597 [[overrides]]
598 platform = "cfg(windows)"
599 ui.show-progress = "counter"
600 ui.max-progress-running = 4
601
602 [[overrides]]
603 platform = "cfg(unix)"
604 ui.input-handler = false
605 "#;
606
607 let temp_dir = tempdir().unwrap();
608 let config_path = temp_dir.path().join("config.toml");
609 std::fs::write(&config_path, config_contents).unwrap();
610
611 let mut warnings = TestUserConfigWarnings::default();
612 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
613 .expect("config valid")
614 .expect("config should exist");
615
616 assert!(
617 warnings.unknown_keys.is_none(),
618 "no unknown keys should be detected"
619 );
620 assert_eq!(config.ui_overrides.len(), 2, "should have 2 UI overrides");
621 assert_eq!(
622 config.record_overrides.len(),
623 2,
624 "should have 2 record overrides"
625 );
626 }
627
628 #[test]
629 fn overrides_record_parsing() {
630 let config_contents = r#"
631 [record]
632 enabled = false
633
634 [[overrides]]
635 platform = "cfg(unix)"
636 record.enabled = true
637 record.max-output-size = "50MB"
638
639 [[overrides]]
640 platform = "cfg(windows)"
641 record.enabled = true
642 record.max-records = 200
643 "#;
644
645 let temp_dir = tempdir().unwrap();
646 let config_path = temp_dir.path().join("config.toml");
647 std::fs::write(&config_path, config_contents).unwrap();
648
649 let mut warnings = TestUserConfigWarnings::default();
650 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
651 .expect("config valid")
652 .expect("config should exist");
653
654 assert!(
655 warnings.unknown_keys.is_none(),
656 "no unknown keys should be detected"
657 );
658 assert_eq!(
659 config.record_overrides.len(),
660 2,
661 "should have 2 record overrides"
662 );
663 }
664
665 #[test]
666 fn overrides_record_unknown_key() {
667 let config_contents = r#"
668 [[overrides]]
669 platform = "cfg(unix)"
670 record.enabled = true
671 record.unknown-key = "test"
672 "#;
673
674 let temp_dir = tempdir().unwrap();
675 let config_path = temp_dir.path().join("config.toml");
676 std::fs::write(&config_path, config_contents).unwrap();
677
678 let mut warnings = TestUserConfigWarnings::default();
679 let _config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
680 .expect("config valid")
681 .expect("config should exist");
682
683 let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
684 assert_eq!(path, config_path, "path should match");
685 assert!(
686 unknown.contains("overrides.0.record.unknown-key"),
687 "unknown key should be detected: {unknown:?}"
688 );
689 }
690
691 #[test]
692 fn overrides_invalid_platform() {
693 let config_contents = r#"
694 [ui]
695 show-progress = "bar"
696
697 [[overrides]]
698 platform = "invalid platform spec!!!"
699 ui.show-progress = "counter"
700 "#;
701
702 let temp_dir = tempdir().unwrap();
703 let config_path = temp_dir.path().join("config.toml");
704 std::fs::write(&config_path, config_contents).unwrap();
705
706 let mut warnings = TestUserConfigWarnings::default();
707 let result = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings);
708
709 assert!(
710 matches!(
711 result,
712 Err(UserConfigError::OverridePlatformSpec { index: 0, .. })
713 ),
714 "should fail with platform spec error at index 0"
715 );
716 }
717
718 #[test]
719 fn overrides_missing_platform() {
720 let config_contents = r#"
721 [ui]
722 show-progress = "bar"
723
724 [[overrides]]
725 # platform field is missing - should fail to parse
726 ui.show-progress = "counter"
727 "#;
728
729 let temp_dir = tempdir().unwrap();
730 let config_path = temp_dir.path().join("config.toml");
731 std::fs::write(&config_path, config_contents).unwrap();
732
733 let mut warnings = TestUserConfigWarnings::default();
734 let result = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings);
735
736 assert!(
737 matches!(result, Err(UserConfigError::Parse { .. })),
738 "should fail with parse error due to missing required platform field: {result:?}"
739 );
740 }
741
742 #[test]
743 fn experimental_features_parsing() {
744 let config_contents = r#"
745 [experimental]
746 record = true
747
748 [ui]
749 show-progress = "bar"
750 "#;
751
752 let temp_dir = tempdir().unwrap();
753 let config_path = temp_dir.path().join("config.toml");
754 std::fs::write(&config_path, config_contents).unwrap();
755
756 let mut warnings = TestUserConfigWarnings::default();
757 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
758 .expect("config valid")
759 .expect("config should exist");
760
761 assert!(
762 warnings.unknown_keys.is_none(),
763 "no unknown keys should be detected"
764 );
765 assert!(
766 config
767 .experimental
768 .contains(&UserConfigExperimental::Record),
769 "record feature should be enabled"
770 );
771 }
772
773 #[test]
774 fn experimental_features_disabled() {
775 let config_contents = r#"
776 [experimental]
777 record = false
778
779 [ui]
780 show-progress = "bar"
781 "#;
782
783 let temp_dir = tempdir().unwrap();
784 let config_path = temp_dir.path().join("config.toml");
785 std::fs::write(&config_path, config_contents).unwrap();
786
787 let mut warnings = TestUserConfigWarnings::default();
788 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
789 .expect("config valid")
790 .expect("config should exist");
791
792 assert!(
793 warnings.unknown_keys.is_none(),
794 "no unknown keys should be detected"
795 );
796 assert!(
797 !config
798 .experimental
799 .contains(&UserConfigExperimental::Record),
800 "record feature should not be enabled"
801 );
802 }
803
804 #[test]
805 fn experimental_features_unknown_warning() {
806 let config_contents = r#"
807 [experimental]
808 record = true
809 unknown-feature = true
810
811 [ui]
812 show-progress = "bar"
813 "#;
814
815 let temp_dir = tempdir().unwrap();
816 let config_path = temp_dir.path().join("config.toml");
817 std::fs::write(&config_path, config_contents).unwrap();
818
819 let mut warnings = TestUserConfigWarnings::default();
820 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
821 .expect("config valid")
822 .expect("config should exist");
823
824 let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
826 assert_eq!(path, config_path, "path should match");
827 assert!(
828 unknown.contains("experimental.unknown-feature"),
829 "unknown key should be detected: {unknown:?}"
830 );
831
832 assert!(
834 config
835 .experimental
836 .contains(&UserConfigExperimental::Record),
837 "record feature should be enabled"
838 );
839 }
840}