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 for the given host platform.
71    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        // Combine experimental features from user config and environment variables.
79        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    /// Returns true if the specified experimental feature is enabled.
114    pub fn is_experimental_enabled(&self, feature: UserConfigExperimental) -> bool {
115        self.experimental.contains(&feature)
116    }
117}
118
119/// Trait for handling user configuration warnings.
120///
121/// This trait allows for different warning handling strategies, such as logging
122/// warnings (the default behavior) or collecting them for testing purposes.
123trait UserConfigWarnings {
124    /// Handle unknown configuration keys found in a user config file.
125    fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>);
126}
127
128/// Default implementation of UserConfigWarnings that logs warnings using the
129/// tracing crate.
130struct 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            // Print this on the same line.
137            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/// User-specific configuration (deserialized form).
156///
157/// This configuration is loaded from the user's config directory and contains
158/// personal preferences that shouldn't be version-controlled.
159///
160/// Use [`DeserializedUserConfig::compile`] to compile platform specs and get a
161/// [`CompiledUserConfig`].
162#[derive(Clone, Debug, Default, Deserialize)]
163#[serde(rename_all = "kebab-case")]
164struct DeserializedUserConfig {
165    /// Experimental features to enable.
166    ///
167    /// This is a table with boolean fields for each experimental feature:
168    ///
169    /// ```toml
170    /// [experimental]
171    /// record = true
172    /// ```
173    #[serde(default)]
174    experimental: ExperimentalConfig,
175
176    /// UI configuration.
177    #[serde(default)]
178    ui: DeserializedUiConfig,
179
180    /// Record configuration.
181    #[serde(default)]
182    record: DeserializedRecordConfig,
183
184    /// Configuration overrides.
185    #[serde(default)]
186    overrides: Vec<DeserializedOverride>,
187}
188
189/// Deserialized form of a single override entry.
190///
191/// Each override has a platform filter and optional settings for different
192/// configuration sections.
193#[derive(Clone, Debug, Deserialize)]
194#[serde(rename_all = "kebab-case")]
195struct DeserializedOverride {
196    /// Platform to match (required).
197    ///
198    /// This is a target-spec expression like `cfg(windows)` or
199    /// `x86_64-unknown-linux-gnu`.
200    platform: String,
201
202    /// UI settings to override.
203    #[serde(default)]
204    ui: DeserializedUiOverrideData,
205
206    /// Record settings to override.
207    #[serde(default)]
208    record: DeserializedRecordOverrideData,
209}
210
211impl DeserializedUserConfig {
212    /// Loads user config from a specific path with custom warning handling.
213    ///
214    /// Returns `Ok(None)` if the file does not exist.
215    /// Returns `Err` if the file exists but cannot be read or parsed.
216    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    /// Deserializes TOML content and returns the config along with any unknown keys.
250    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    /// Compiles the user config by parsing platform specs in overrides.
260    ///
261    /// The `path` is used for error reporting.
262    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            // Each override entry uses the same platform spec for both UI and
274            // record settings.
275            ui_overrides.push(CompiledUiOverride::new(platform_spec.clone(), override_.ui));
276            record_overrides.push(CompiledRecordOverride::new(platform_spec, override_.record));
277        }
278
279        // Convert the experimental config table to a set of enabled features.
280        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/// Compiled user configuration with parsed platform specs.
293///
294/// This is created from [`DeserializedUserConfig`] after compiling platform
295/// expressions in overrides.
296#[derive(Clone, Debug)]
297pub(super) struct CompiledUserConfig {
298    /// Experimental features enabled in user config.
299    pub(super) experimental: BTreeSet<UserConfigExperimental>,
300    /// UI configuration.
301    pub(super) ui: DeserializedUiConfig,
302    /// Record configuration.
303    pub(super) record: DeserializedRecordConfig,
304    /// Compiled UI overrides with parsed platform specs.
305    pub(super) ui_overrides: Vec<CompiledUiOverride>,
306    /// Compiled record overrides with parsed platform specs.
307    pub(super) record_overrides: Vec<CompiledRecordOverride>,
308}
309
310impl CompiledUserConfig {
311    /// Loads and compiles user config from the specified location.
312    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    /// Loads and compiles user config from the specified location, with custom
319    /// warning handling.
320    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    /// Loads and compiles user config from the default location, with custom
343    /// warning handling.
344    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    /// Loads and compiles user config from a specific path with custom warning
368    /// handling.
369    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/// Deserialized form of the default user config before compilation.
381///
382/// This includes both base settings (all required) and platform-specific
383/// overrides.
384#[derive(Clone, Debug, Deserialize)]
385#[serde(rename_all = "kebab-case")]
386struct DeserializedDefaultUserConfig {
387    /// UI configuration (base settings, all required).
388    ui: DefaultUiConfig,
389
390    /// Record configuration (base settings, all required).
391    record: DefaultRecordConfig,
392
393    /// Configuration overrides.
394    #[serde(default)]
395    overrides: Vec<DeserializedOverride>,
396}
397
398/// Default user configuration parsed from the embedded TOML.
399///
400/// This contains both the base settings (all required) and compiled
401/// platform-specific overrides.
402#[derive(Clone, Debug)]
403pub(super) struct DefaultUserConfig {
404    /// Base UI configuration.
405    pub(super) ui: DefaultUiConfig,
406
407    /// Base record configuration.
408    pub(super) record: DefaultRecordConfig,
409
410    /// Compiled UI overrides with parsed platform specs.
411    pub(super) ui_overrides: Vec<CompiledUiOverride>,
412
413    /// Compiled record overrides with parsed platform specs.
414    pub(super) record_overrides: Vec<CompiledRecordOverride>,
415}
416
417impl DefaultUserConfig {
418    /// The embedded default user config TOML.
419    const DEFAULT_CONFIG: &'static str = include_str!("../../default-user-config.toml");
420
421    /// Parses and compiles the default config.
422    ///
423    /// Panics if the embedded TOML is invalid, contains unknown keys, or has
424    /// invalid platform specs in overrides.
425    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        // Make sure there aren't any unknown keys in the default config, since it is
436        // embedded/shipped with this binary.
437        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        // Compile platform specs in overrides.
445        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            // Each override entry uses the same platform spec for both UI and
455            // record settings.
456            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    /// Test implementation of UserConfigWarnings that collects warnings for testing.
476    #[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        // This will panic if the TOML is missing any required fields, or has
490        // unknown keys.
491        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        // Unknown fields should be warnings, not errors.
793        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        // The known feature should still be enabled.
801        assert!(
802            config
803                .experimental
804                .contains(&UserConfigExperimental::Record),
805            "record feature should be enabled"
806        );
807    }
808}