Skip to main content

nextest_runner/user_config/
imp.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! User config implementation.
5
6use 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
22/// Special value for `--user-config-file` and `NEXTEST_USER_CONFIG_FILE` that
23/// skips user config loading entirely.
24pub const USER_CONFIG_NONE: &str = "none";
25
26/// Specifies where to load user configuration from.
27#[derive(Clone, Copy, Debug)]
28pub enum UserConfigLocation<'a> {
29    /// Discover user config from default locations (e.g.,
30    /// `~/.config/nextest/config.toml`).
31    Default,
32
33    /// Skip user config loading entirely, using only built-in defaults.
34    ///
35    /// This is useful for test isolation.
36    Isolated,
37
38    /// Load user config from an explicit path.
39    ///
40    /// Returns an error if the file does not exist.
41    Explicit(&'a Utf8Path),
42}
43
44impl<'a> UserConfigLocation<'a> {
45    /// Creates a user config location from a CLI or environment variable value.
46    ///
47    /// Returns `Default` if `None`, `Isolated` if `"none"`, otherwise
48    /// `Explicit` with the path.
49    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/// User configuration after custom settings and overrides have been applied.
59#[derive(Clone, Debug)]
60pub struct UserConfig {
61    /// Experimental features enabled (from config and environment variables).
62    pub experimental: BTreeSet<UserConfigExperimental>,
63    /// Resolved UI configuration.
64    pub ui: UiConfig,
65    /// Resolved record configuration.
66    pub record: RecordConfig,
67}
68
69impl UserConfig {
70    /// Loads and resolves user configuration.
71    ///
72    /// Platform overrides in the user config are evaluated against the build
73    /// target of the nextest binary (via [`Platform::build_target`]), not
74    /// against the host platform reported by `rustc -vV`. User config expresses
75    /// per-user preferences for the running nextest binary, so the binary's
76    /// build target is the right thing to match against — and this keeps
77    /// resolution consistent across normal runs, archive replay, and commands
78    /// that don't otherwise need to detect a host platform.
79    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        // Combine experimental features from user config and environment variables.
87        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    /// Returns true if the specified experimental feature is enabled.
122    pub fn is_experimental_enabled(&self, feature: UserConfigExperimental) -> bool {
123        self.experimental.contains(&feature)
124    }
125}
126
127/// Trait for handling user configuration warnings.
128///
129/// This trait allows for different warning handling strategies, such as logging
130/// warnings (the default behavior) or collecting them for testing purposes.
131trait UserConfigWarnings {
132    /// Handle unknown configuration keys found in a user config file.
133    fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>);
134}
135
136/// Default implementation of UserConfigWarnings that logs warnings using the
137/// tracing crate.
138struct 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            // Print this on the same line.
145            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/// User-specific configuration (deserialized form).
164///
165/// This configuration is loaded from the user's config directory and contains
166/// personal preferences that shouldn't be version-controlled.
167///
168/// Use [`DeserializedUserConfig::compile`] to compile platform specs and get a
169/// [`CompiledUserConfig`].
170#[derive(Clone, Debug, Default, Deserialize)]
171#[serde(rename_all = "kebab-case")]
172struct DeserializedUserConfig {
173    /// Experimental features to enable.
174    ///
175    /// This is a table with boolean fields for each experimental feature:
176    ///
177    /// ```toml
178    /// [experimental]
179    /// record = true
180    /// ```
181    #[serde(default)]
182    experimental: ExperimentalConfig,
183
184    /// UI configuration.
185    #[serde(default)]
186    ui: DeserializedUiConfig,
187
188    /// Record configuration.
189    #[serde(default)]
190    record: DeserializedRecordConfig,
191
192    /// Configuration overrides.
193    #[serde(default)]
194    overrides: Vec<DeserializedOverride>,
195}
196
197/// Deserialized form of a single override entry.
198///
199/// Each override has a platform filter and optional settings for different
200/// configuration sections.
201#[derive(Clone, Debug, Deserialize)]
202#[serde(rename_all = "kebab-case")]
203struct DeserializedOverride {
204    /// Platform to match (required).
205    ///
206    /// This is a target-spec expression like `cfg(windows)` or
207    /// `x86_64-unknown-linux-gnu`.
208    platform: String,
209
210    /// UI settings to override.
211    #[serde(default)]
212    ui: DeserializedUiOverrideData,
213
214    /// Record settings to override.
215    #[serde(default)]
216    record: DeserializedRecordOverrideData,
217}
218
219impl DeserializedUserConfig {
220    /// Loads user config from a specific path with custom warning handling.
221    ///
222    /// Returns `Ok(None)` if the file does not exist.
223    /// Returns `Err` if the file exists but cannot be read or parsed.
224    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    /// Deserializes TOML content and returns the config along with any unknown keys.
258    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    /// Compiles the user config by parsing platform specs in overrides.
268    ///
269    /// The `path` is used for error reporting.
270    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            // Each override entry uses the same platform spec for both UI and
282            // record settings.
283            ui_overrides.push(CompiledUiOverride::new(platform_spec.clone(), override_.ui));
284            record_overrides.push(CompiledRecordOverride::new(platform_spec, override_.record));
285        }
286
287        // Convert the experimental config table to a set of enabled features.
288        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/// Compiled user configuration with parsed platform specs.
301///
302/// This is created from [`DeserializedUserConfig`] after compiling platform
303/// expressions in overrides.
304#[derive(Clone, Debug)]
305pub(super) struct CompiledUserConfig {
306    /// Experimental features enabled in user config.
307    pub(super) experimental: BTreeSet<UserConfigExperimental>,
308    /// UI configuration.
309    pub(super) ui: DeserializedUiConfig,
310    /// Record configuration.
311    pub(super) record: DeserializedRecordConfig,
312    /// Compiled UI overrides with parsed platform specs.
313    pub(super) ui_overrides: Vec<CompiledUiOverride>,
314    /// Compiled record overrides with parsed platform specs.
315    pub(super) record_overrides: Vec<CompiledRecordOverride>,
316}
317
318impl CompiledUserConfig {
319    /// Loads and compiles user config from the specified location.
320    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    /// Loads and compiles user config from the specified location, with custom
327    /// warning handling.
328    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    /// Loads and compiles user config from the default location, with custom
351    /// warning handling.
352    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    /// Loads and compiles user config from a specific path with custom warning
376    /// handling.
377    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/// Deserialized form of the default user config before compilation.
389///
390/// This includes both base settings (all required) and platform-specific
391/// overrides.
392#[derive(Clone, Debug, Deserialize)]
393#[serde(rename_all = "kebab-case")]
394struct DeserializedDefaultUserConfig {
395    /// UI configuration (base settings, all required).
396    ui: DefaultUiConfig,
397
398    /// Record configuration (base settings, all required).
399    record: DefaultRecordConfig,
400
401    /// Configuration overrides.
402    #[serde(default)]
403    overrides: Vec<DeserializedOverride>,
404}
405
406/// Default user configuration parsed from the embedded TOML.
407///
408/// This contains both the base settings (all required) and compiled
409/// platform-specific overrides.
410#[derive(Clone, Debug)]
411pub(super) struct DefaultUserConfig {
412    /// Base UI configuration.
413    pub(super) ui: DefaultUiConfig,
414
415    /// Base record configuration.
416    pub(super) record: DefaultRecordConfig,
417
418    /// Compiled UI overrides with parsed platform specs.
419    pub(super) ui_overrides: Vec<CompiledUiOverride>,
420
421    /// Compiled record overrides with parsed platform specs.
422    pub(super) record_overrides: Vec<CompiledRecordOverride>,
423}
424
425impl DefaultUserConfig {
426    /// The embedded default user config TOML.
427    const DEFAULT_CONFIG: &'static str = include_str!("../../default-user-config.toml");
428
429    /// Parses and compiles the default config.
430    ///
431    /// Panics if the embedded TOML is invalid, contains unknown keys, or has
432    /// invalid platform specs in overrides.
433    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        // Make sure there aren't any unknown keys in the default config, since it is
444        // embedded/shipped with this binary.
445        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        // Compile platform specs in overrides.
453        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            // Each override entry uses the same platform spec for both UI and
463            // record settings.
464            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    /// Test implementation of UserConfigWarnings that collects warnings for testing.
484    #[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        // This will panic if the TOML is missing any required fields, or has
498        // unknown keys.
499        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        // Unknown fields should be warnings, not errors.
801        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        // The known feature should still be enabled.
809        assert!(
810            config
811                .experimental
812                .contains(&UserConfigExperimental::Record),
813            "record feature should be enabled"
814        );
815    }
816}