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 for_host_platform(
72 host_platform: &Platform,
73 location: UserConfigLocation<'_>,
74 ) -> Result<Self, UserConfigError> {
75 let user_config = CompiledUserConfig::from_location(location)?;
76 let default_user_config = DefaultUserConfig::from_embedded();
77
78 let mut experimental = UserConfigExperimental::from_env();
80 if let Some(config) = &user_config {
81 experimental.extend(config.experimental.iter().copied());
82 }
83
84 let resolved_ui = UiConfig::resolve(
85 &default_user_config.ui,
86 &default_user_config.ui_overrides,
87 user_config.as_ref().map(|c| &c.ui),
88 user_config
89 .as_ref()
90 .map(|c| &c.ui_overrides[..])
91 .unwrap_or(&[]),
92 host_platform,
93 );
94
95 let resolved_record = RecordConfig::resolve(
96 &default_user_config.record,
97 &default_user_config.record_overrides,
98 user_config.as_ref().map(|c| &c.record),
99 user_config
100 .as_ref()
101 .map(|c| &c.record_overrides[..])
102 .unwrap_or(&[]),
103 host_platform,
104 );
105
106 Ok(Self {
107 experimental,
108 ui: resolved_ui,
109 record: resolved_record,
110 })
111 }
112
113 pub fn is_experimental_enabled(&self, feature: UserConfigExperimental) -> bool {
115 self.experimental.contains(&feature)
116 }
117}
118
119trait UserConfigWarnings {
124 fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>);
126}
127
128struct DefaultUserConfigWarnings;
131
132impl UserConfigWarnings for DefaultUserConfigWarnings {
133 fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>) {
134 let mut unknown_str = String::new();
135 if unknown.len() == 1 {
136 unknown_str.push_str("key: ");
138 unknown_str.push_str(unknown.iter().next().unwrap());
139 } else {
140 unknown_str.push_str("keys:\n");
141 for ignored_key in unknown {
142 unknown_str.push('\n');
143 unknown_str.push_str(" - ");
144 unknown_str.push_str(ignored_key);
145 }
146 }
147
148 warn!(
149 "in user config file {}, ignoring unknown configuration {unknown_str}",
150 config_file,
151 );
152 }
153}
154
155#[derive(Clone, Debug, Default, Deserialize)]
163#[serde(rename_all = "kebab-case")]
164struct DeserializedUserConfig {
165 #[serde(default)]
174 experimental: ExperimentalConfig,
175
176 #[serde(default)]
178 ui: DeserializedUiConfig,
179
180 #[serde(default)]
182 record: DeserializedRecordConfig,
183
184 #[serde(default)]
186 overrides: Vec<DeserializedOverride>,
187}
188
189#[derive(Clone, Debug, Deserialize)]
194#[serde(rename_all = "kebab-case")]
195struct DeserializedOverride {
196 platform: String,
201
202 #[serde(default)]
204 ui: DeserializedUiOverrideData,
205
206 #[serde(default)]
208 record: DeserializedRecordOverrideData,
209}
210
211impl DeserializedUserConfig {
212 fn from_path_with_warnings(
217 path: &Utf8Path,
218 warnings: &mut impl UserConfigWarnings,
219 ) -> Result<Option<Self>, UserConfigError> {
220 debug!("user config: attempting to load from {path}");
221 let contents = match std::fs::read_to_string(path) {
222 Ok(contents) => contents,
223 Err(error) if error.kind() == io::ErrorKind::NotFound => {
224 debug!("user config: file does not exist at {path}");
225 return Ok(None);
226 }
227 Err(error) => {
228 return Err(UserConfigError::Read {
229 path: path.to_owned(),
230 error,
231 });
232 }
233 };
234
235 let (config, unknown) =
236 Self::deserialize_toml(&contents).map_err(|error| UserConfigError::Parse {
237 path: path.to_owned(),
238 error,
239 })?;
240
241 if !unknown.is_empty() {
242 warnings.unknown_config_keys(path, &unknown);
243 }
244
245 debug!("user config: loaded successfully from {path}");
246 Ok(Some(config))
247 }
248
249 fn deserialize_toml(contents: &str) -> Result<(Self, BTreeSet<String>), toml::de::Error> {
251 let deserializer = toml::Deserializer::parse(contents)?;
252 let mut unknown = BTreeSet::new();
253 let config: DeserializedUserConfig = serde_ignored::deserialize(deserializer, |path| {
254 unknown.insert(path.to_string());
255 })?;
256 Ok((config, unknown))
257 }
258
259 fn compile(self, path: &Utf8Path) -> Result<CompiledUserConfig, UserConfigError> {
263 let mut ui_overrides = Vec::with_capacity(self.overrides.len());
264 let mut record_overrides = Vec::with_capacity(self.overrides.len());
265 for (index, override_) in self.overrides.into_iter().enumerate() {
266 let platform_spec = TargetSpec::new(override_.platform).map_err(|error| {
267 UserConfigError::OverridePlatformSpec {
268 path: path.to_owned(),
269 index,
270 error,
271 }
272 })?;
273 ui_overrides.push(CompiledUiOverride::new(platform_spec.clone(), override_.ui));
276 record_overrides.push(CompiledRecordOverride::new(platform_spec, override_.record));
277 }
278
279 let experimental = self.experimental.to_set();
281
282 Ok(CompiledUserConfig {
283 experimental,
284 ui: self.ui,
285 record: self.record,
286 ui_overrides,
287 record_overrides,
288 })
289 }
290}
291
292#[derive(Clone, Debug)]
297pub(super) struct CompiledUserConfig {
298 pub(super) experimental: BTreeSet<UserConfigExperimental>,
300 pub(super) ui: DeserializedUiConfig,
302 pub(super) record: DeserializedRecordConfig,
304 pub(super) ui_overrides: Vec<CompiledUiOverride>,
306 pub(super) record_overrides: Vec<CompiledRecordOverride>,
308}
309
310impl CompiledUserConfig {
311 pub(super) fn from_location(
313 location: UserConfigLocation<'_>,
314 ) -> Result<Option<Self>, UserConfigError> {
315 Self::from_location_with_warnings(location, &mut DefaultUserConfigWarnings)
316 }
317
318 fn from_location_with_warnings(
321 location: UserConfigLocation<'_>,
322 warnings: &mut impl UserConfigWarnings,
323 ) -> Result<Option<Self>, UserConfigError> {
324 match location {
325 UserConfigLocation::Isolated => {
326 debug!("user config: skipping (isolated)");
327 Ok(None)
328 }
329 UserConfigLocation::Explicit(path) => {
330 debug!("user config: loading from explicit path {path}");
331 match Self::from_path_with_warnings(path, warnings)? {
332 Some(config) => Ok(Some(config)),
333 None => Err(UserConfigError::FileNotFound {
334 path: path.to_owned(),
335 }),
336 }
337 }
338 UserConfigLocation::Default => Self::from_default_location_with_warnings(warnings),
339 }
340 }
341
342 fn from_default_location_with_warnings(
345 warnings: &mut impl UserConfigWarnings,
346 ) -> Result<Option<Self>, UserConfigError> {
347 let paths = user_config_paths()?;
348 if paths.is_empty() {
349 debug!("user config: could not determine config directory");
350 return Ok(None);
351 }
352
353 for path in &paths {
354 match Self::from_path_with_warnings(path, warnings)? {
355 Some(config) => return Ok(Some(config)),
356 None => continue,
357 }
358 }
359
360 debug!(
361 "user config: no config file found at any candidate path: {:?}",
362 paths
363 );
364 Ok(None)
365 }
366
367 fn from_path_with_warnings(
370 path: &Utf8Path,
371 warnings: &mut impl UserConfigWarnings,
372 ) -> Result<Option<Self>, UserConfigError> {
373 match DeserializedUserConfig::from_path_with_warnings(path, warnings)? {
374 Some(config) => Ok(Some(config.compile(path)?)),
375 None => Ok(None),
376 }
377 }
378}
379
380#[derive(Clone, Debug, Deserialize)]
385#[serde(rename_all = "kebab-case")]
386struct DeserializedDefaultUserConfig {
387 ui: DefaultUiConfig,
389
390 record: DefaultRecordConfig,
392
393 #[serde(default)]
395 overrides: Vec<DeserializedOverride>,
396}
397
398#[derive(Clone, Debug)]
403pub(super) struct DefaultUserConfig {
404 pub(super) ui: DefaultUiConfig,
406
407 pub(super) record: DefaultRecordConfig,
409
410 pub(super) ui_overrides: Vec<CompiledUiOverride>,
412
413 pub(super) record_overrides: Vec<CompiledRecordOverride>,
415}
416
417impl DefaultUserConfig {
418 const DEFAULT_CONFIG: &'static str = include_str!("../../default-user-config.toml");
420
421 pub(crate) fn from_embedded() -> Self {
426 let deserializer = toml::Deserializer::parse(Self::DEFAULT_CONFIG)
427 .expect("embedded default user config should parse");
428 let mut unknown = BTreeSet::new();
429 let config: DeserializedDefaultUserConfig =
430 serde_ignored::deserialize(deserializer, |path: serde_ignored::Path| {
431 unknown.insert(path.to_string());
432 })
433 .expect("embedded default user config should be valid");
434
435 if !unknown.is_empty() {
438 panic!(
439 "found unknown keys in default user config: {}",
440 unknown.into_iter().collect::<Vec<_>>().join(", ")
441 );
442 }
443
444 let mut ui_overrides = Vec::with_capacity(config.overrides.len());
446 let mut record_overrides = Vec::with_capacity(config.overrides.len());
447 for (index, override_) in config.overrides.into_iter().enumerate() {
448 let platform_spec = TargetSpec::new(override_.platform).unwrap_or_else(|error| {
449 panic!(
450 "embedded default user config has invalid platform spec \
451 in [[overrides]] at index {index}: {error}"
452 )
453 });
454 ui_overrides.push(CompiledUiOverride::new(platform_spec.clone(), override_.ui));
457 record_overrides.push(CompiledRecordOverride::new(platform_spec, override_.record));
458 }
459
460 Self {
461 ui: config.ui,
462 record: config.record,
463 ui_overrides,
464 record_overrides,
465 }
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use camino::Utf8PathBuf;
473 use camino_tempfile::tempdir;
474
475 #[derive(Default)]
477 struct TestUserConfigWarnings {
478 unknown_keys: Option<(Utf8PathBuf, BTreeSet<String>)>,
479 }
480
481 impl UserConfigWarnings for TestUserConfigWarnings {
482 fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>) {
483 self.unknown_keys = Some((config_file.to_owned(), unknown.clone()));
484 }
485 }
486
487 #[test]
488 fn default_user_config_is_valid() {
489 let _ = DefaultUserConfig::from_embedded();
492 }
493
494 #[test]
495 fn ignored_keys() {
496 let config_contents = r#"
497 ignored1 = "test"
498
499 [ui]
500 show-progress = "bar"
501 ignored2 = "hi"
502 "#;
503
504 let temp_dir = tempdir().unwrap();
505 let config_path = temp_dir.path().join("config.toml");
506 std::fs::write(&config_path, config_contents).unwrap();
507
508 let mut warnings = TestUserConfigWarnings::default();
509 let config = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings)
510 .expect("config valid");
511
512 assert!(config.is_some(), "config should be loaded");
513 let config = config.unwrap();
514 assert!(
515 matches!(
516 config.ui.show_progress,
517 Some(crate::user_config::elements::UiShowProgress::Bar)
518 ),
519 "show-progress should be parsed correctly"
520 );
521
522 let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
523 assert_eq!(path, config_path, "path should match");
524 assert_eq!(
525 unknown,
526 maplit::btreeset! {
527 "ignored1".to_owned(),
528 "ui.ignored2".to_owned(),
529 },
530 "unknown keys should be detected"
531 );
532 }
533
534 #[test]
535 fn no_ignored_keys() {
536 let config_contents = r#"
537 [ui]
538 show-progress = "counter"
539 max-progress-running = 10
540 input-handler = false
541 output-indent = true
542 "#;
543
544 let temp_dir = tempdir().unwrap();
545 let config_path = temp_dir.path().join("config.toml");
546 std::fs::write(&config_path, config_contents).unwrap();
547
548 let mut warnings = TestUserConfigWarnings::default();
549 let config = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings)
550 .expect("config valid");
551
552 assert!(config.is_some(), "config should be loaded");
553 assert!(
554 warnings.unknown_keys.is_none(),
555 "no unknown keys should be detected"
556 );
557 }
558
559 #[test]
560 fn overrides_parsing() {
561 let config_contents = r#"
562 [ui]
563 show-progress = "bar"
564
565 [[overrides]]
566 platform = "cfg(windows)"
567 ui.show-progress = "counter"
568 ui.max-progress-running = 4
569
570 [[overrides]]
571 platform = "cfg(unix)"
572 ui.input-handler = false
573 "#;
574
575 let temp_dir = tempdir().unwrap();
576 let config_path = temp_dir.path().join("config.toml");
577 std::fs::write(&config_path, config_contents).unwrap();
578
579 let mut warnings = TestUserConfigWarnings::default();
580 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
581 .expect("config valid")
582 .expect("config should exist");
583
584 assert!(
585 warnings.unknown_keys.is_none(),
586 "no unknown keys should be detected"
587 );
588 assert_eq!(config.ui_overrides.len(), 2, "should have 2 UI overrides");
589 assert_eq!(
590 config.record_overrides.len(),
591 2,
592 "should have 2 record overrides"
593 );
594 }
595
596 #[test]
597 fn overrides_record_parsing() {
598 let config_contents = r#"
599 [record]
600 enabled = false
601
602 [[overrides]]
603 platform = "cfg(unix)"
604 record.enabled = true
605 record.max-output-size = "50MB"
606
607 [[overrides]]
608 platform = "cfg(windows)"
609 record.enabled = true
610 record.max-records = 200
611 "#;
612
613 let temp_dir = tempdir().unwrap();
614 let config_path = temp_dir.path().join("config.toml");
615 std::fs::write(&config_path, config_contents).unwrap();
616
617 let mut warnings = TestUserConfigWarnings::default();
618 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
619 .expect("config valid")
620 .expect("config should exist");
621
622 assert!(
623 warnings.unknown_keys.is_none(),
624 "no unknown keys should be detected"
625 );
626 assert_eq!(
627 config.record_overrides.len(),
628 2,
629 "should have 2 record overrides"
630 );
631 }
632
633 #[test]
634 fn overrides_record_unknown_key() {
635 let config_contents = r#"
636 [[overrides]]
637 platform = "cfg(unix)"
638 record.enabled = true
639 record.unknown-key = "test"
640 "#;
641
642 let temp_dir = tempdir().unwrap();
643 let config_path = temp_dir.path().join("config.toml");
644 std::fs::write(&config_path, config_contents).unwrap();
645
646 let mut warnings = TestUserConfigWarnings::default();
647 let _config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
648 .expect("config valid")
649 .expect("config should exist");
650
651 let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
652 assert_eq!(path, config_path, "path should match");
653 assert!(
654 unknown.contains("overrides.0.record.unknown-key"),
655 "unknown key should be detected: {unknown:?}"
656 );
657 }
658
659 #[test]
660 fn overrides_invalid_platform() {
661 let config_contents = r#"
662 [ui]
663 show-progress = "bar"
664
665 [[overrides]]
666 platform = "invalid platform spec!!!"
667 ui.show-progress = "counter"
668 "#;
669
670 let temp_dir = tempdir().unwrap();
671 let config_path = temp_dir.path().join("config.toml");
672 std::fs::write(&config_path, config_contents).unwrap();
673
674 let mut warnings = TestUserConfigWarnings::default();
675 let result = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings);
676
677 assert!(
678 matches!(
679 result,
680 Err(UserConfigError::OverridePlatformSpec { index: 0, .. })
681 ),
682 "should fail with platform spec error at index 0"
683 );
684 }
685
686 #[test]
687 fn overrides_missing_platform() {
688 let config_contents = r#"
689 [ui]
690 show-progress = "bar"
691
692 [[overrides]]
693 # platform field is missing - should fail to parse
694 ui.show-progress = "counter"
695 "#;
696
697 let temp_dir = tempdir().unwrap();
698 let config_path = temp_dir.path().join("config.toml");
699 std::fs::write(&config_path, config_contents).unwrap();
700
701 let mut warnings = TestUserConfigWarnings::default();
702 let result = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings);
703
704 assert!(
705 matches!(result, Err(UserConfigError::Parse { .. })),
706 "should fail with parse error due to missing required platform field: {result:?}"
707 );
708 }
709
710 #[test]
711 fn experimental_features_parsing() {
712 let config_contents = r#"
713 [experimental]
714 record = true
715
716 [ui]
717 show-progress = "bar"
718 "#;
719
720 let temp_dir = tempdir().unwrap();
721 let config_path = temp_dir.path().join("config.toml");
722 std::fs::write(&config_path, config_contents).unwrap();
723
724 let mut warnings = TestUserConfigWarnings::default();
725 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
726 .expect("config valid")
727 .expect("config should exist");
728
729 assert!(
730 warnings.unknown_keys.is_none(),
731 "no unknown keys should be detected"
732 );
733 assert!(
734 config
735 .experimental
736 .contains(&UserConfigExperimental::Record),
737 "record feature should be enabled"
738 );
739 }
740
741 #[test]
742 fn experimental_features_disabled() {
743 let config_contents = r#"
744 [experimental]
745 record = false
746
747 [ui]
748 show-progress = "bar"
749 "#;
750
751 let temp_dir = tempdir().unwrap();
752 let config_path = temp_dir.path().join("config.toml");
753 std::fs::write(&config_path, config_contents).unwrap();
754
755 let mut warnings = TestUserConfigWarnings::default();
756 let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
757 .expect("config valid")
758 .expect("config should exist");
759
760 assert!(
761 warnings.unknown_keys.is_none(),
762 "no unknown keys should be detected"
763 );
764 assert!(
765 !config
766 .experimental
767 .contains(&UserConfigExperimental::Record),
768 "record feature should not be enabled"
769 );
770 }
771
772 #[test]
773 fn experimental_features_unknown_warning() {
774 let config_contents = r#"
775 [experimental]
776 record = true
777 unknown-feature = true
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 let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
794 assert_eq!(path, config_path, "path should match");
795 assert!(
796 unknown.contains("experimental.unknown-feature"),
797 "unknown key should be detected: {unknown:?}"
798 );
799
800 assert!(
802 config
803 .experimental
804 .contains(&UserConfigExperimental::Record),
805 "record feature should be enabled"
806 );
807 }
808}