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    /// The pregenerated JSON Schema for `config.toml` in the user config
71    /// directory.
72    ///
73    /// The schema is checked into the repository at
74    /// `nextest-runner/jsonschemas/user-config.json`. (If you're working
75    /// within the nextest repository, regenerate the schema with `just
76    /// generate-schemas`.)
77    pub const SCHEMA: &'static str = include_str!("../../jsonschemas/user-config.json");
78
79    /// The embedded default user config TOML.
80    ///
81    /// User-specific configuration is layered on top of this default config.
82    pub const DEFAULT_CONFIG: &'static str = include_str!("../../default-user-config.toml");
83
84    /// Contains the canonical user config reference markdown.
85    pub const REFERENCE_MD: &'static str = include_str!("../../user-config-reference.md");
86
87    /// Loads and resolves user configuration.
88    ///
89    /// Platform overrides in the user config are evaluated against the build
90    /// target of the nextest binary (via [`Platform::build_target`]), not
91    /// against the host platform reported by `rustc -vV`. User config expresses
92    /// per-user preferences for the running nextest binary, so the binary's
93    /// build target is the right thing to match against — and this keeps
94    /// resolution consistent across normal runs, archive replay, and commands
95    /// that don't otherwise need to detect a host platform.
96    pub fn load(location: UserConfigLocation<'_>) -> Result<Self, UserConfigError> {
97        let build_target =
98            Platform::build_target().expect("nextest is built for a supported platform");
99
100        let user_config = CompiledUserConfig::from_location(location)?;
101        let default_user_config = DefaultUserConfig::from_embedded();
102
103        // Combine experimental features from user config and environment variables.
104        let mut experimental = UserConfigExperimental::from_env();
105        if let Some(config) = &user_config {
106            experimental.extend(config.experimental.iter().copied());
107        }
108
109        let resolved_ui = UiConfig::resolve(
110            &default_user_config.ui,
111            &default_user_config.ui_overrides,
112            user_config.as_ref().map(|c| &c.ui),
113            user_config
114                .as_ref()
115                .map(|c| &c.ui_overrides[..])
116                .unwrap_or(&[]),
117            &build_target,
118        );
119
120        let resolved_record = RecordConfig::resolve(
121            &default_user_config.record,
122            &default_user_config.record_overrides,
123            user_config.as_ref().map(|c| &c.record),
124            user_config
125                .as_ref()
126                .map(|c| &c.record_overrides[..])
127                .unwrap_or(&[]),
128            &build_target,
129        );
130
131        Ok(Self {
132            experimental,
133            ui: resolved_ui,
134            record: resolved_record,
135        })
136    }
137
138    /// Returns true if the specified experimental feature is enabled.
139    pub fn is_experimental_enabled(&self, feature: UserConfigExperimental) -> bool {
140        self.experimental.contains(&feature)
141    }
142}
143
144/// Trait for handling user configuration warnings.
145///
146/// This trait allows for different warning handling strategies, such as logging
147/// warnings (the default behavior) or collecting them for testing purposes.
148trait UserConfigWarnings {
149    /// Handle unknown configuration keys found in a user config file.
150    fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>);
151}
152
153/// Default implementation of UserConfigWarnings that logs warnings using the
154/// tracing crate.
155struct DefaultUserConfigWarnings;
156
157impl UserConfigWarnings for DefaultUserConfigWarnings {
158    fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>) {
159        let mut unknown_str = String::new();
160        if unknown.len() == 1 {
161            // Print this on the same line.
162            unknown_str.push_str("key: ");
163            unknown_str.push_str(unknown.iter().next().unwrap());
164        } else {
165            unknown_str.push_str("keys:\n");
166            for ignored_key in unknown {
167                unknown_str.push('\n');
168                unknown_str.push_str("  - ");
169                unknown_str.push_str(ignored_key);
170            }
171        }
172
173        warn!(
174            "in user config file {}, ignoring unknown configuration {unknown_str}",
175            config_file,
176        );
177    }
178}
179
180/// Per-user nextest configuration.
181///
182/// Stores personal preferences such as UI defaults and recording behavior.
183/// This is distinct from the repository config (`.config/nextest.toml`),
184/// which controls test execution.
185///
186/// See [_User configuration reference_](https://nexte.st/docs/user-config/reference)
187/// for details on each setting.
188#[derive(Clone, Debug, Default, Deserialize)]
189#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
190#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
191#[serde(rename_all = "kebab-case")]
192struct DeserializedUserConfig {
193    /// Toggles for experimental, non-stable features.
194    #[serde(default)]
195    experimental: ExperimentalConfig,
196
197    /// Display, progress, and pager settings.
198    #[serde(default)]
199    ui: DeserializedUiConfig,
200
201    /// Retention settings for the record-replay-rerun feature.
202    #[serde(default)]
203    record: DeserializedRecordConfig,
204
205    /// Platform-specific overrides applied on top of `[ui]` and `[record]`.
206    /// The first matching override per setting wins.
207    #[serde(default)]
208    overrides: Vec<DeserializedOverride>,
209}
210
211/// A single platform-specific override entry.
212#[derive(Clone, Debug, Deserialize)]
213#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
214#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
215#[serde(rename_all = "kebab-case")]
216struct DeserializedOverride {
217    /// Target-spec expression selecting which platforms this override applies
218    /// to (target triple or `cfg()` expression). Matched against the platform
219    /// nextest was built for.
220    platform: String,
221
222    /// UI settings substituted on matching platforms.
223    #[serde(default)]
224    ui: DeserializedUiOverrideData,
225
226    /// Record retention settings substituted on matching platforms.
227    #[serde(default)]
228    record: DeserializedRecordOverrideData,
229}
230
231/// Returns the JSON schema for `config.toml` in the user config directory.
232///
233/// As with [`nextest_config_schema`](crate::config::core::nextest_config_schema),
234/// the schema is intentionally stricter than nextest's runtime parser: unknown
235/// fields are errors so that editors flag likely typos, while at runtime they
236/// are warnings so that older nextest binaries can load configs written for
237/// newer versions.
238#[cfg(feature = "config-schema")]
239pub fn user_config_schema() -> schemars::Schema {
240    let mut schema = schemars::schema_for!(DeserializedUserConfig);
241    // This indicates to Tombi that nextest supports TOML 1.1.0.
242    schema.insert(
243        "x-tombi-toml-version".to_owned(),
244        serde_json::Value::String("v1.1.0".to_owned()),
245    );
246    schema
247}
248
249impl DeserializedUserConfig {
250    /// Loads user config from a specific path with custom warning handling.
251    ///
252    /// Returns `Ok(None)` if the file does not exist.
253    /// Returns `Err` if the file exists but cannot be read or parsed.
254    fn from_path_with_warnings(
255        path: &Utf8Path,
256        warnings: &mut impl UserConfigWarnings,
257    ) -> Result<Option<Self>, UserConfigError> {
258        debug!("user config: attempting to load from {path}");
259        let contents = match std::fs::read_to_string(path) {
260            Ok(contents) => contents,
261            Err(error) if error.kind() == io::ErrorKind::NotFound => {
262                debug!("user config: file does not exist at {path}");
263                return Ok(None);
264            }
265            Err(error) => {
266                return Err(UserConfigError::Read {
267                    path: path.to_owned(),
268                    error,
269                });
270            }
271        };
272
273        let (config, unknown) =
274            Self::deserialize_toml(&contents).map_err(|error| UserConfigError::Parse {
275                path: path.to_owned(),
276                error,
277            })?;
278
279        if !unknown.is_empty() {
280            warnings.unknown_config_keys(path, &unknown);
281        }
282
283        debug!("user config: loaded successfully from {path}");
284        Ok(Some(config))
285    }
286
287    /// Deserializes TOML content and returns the config along with any unknown keys.
288    fn deserialize_toml(contents: &str) -> Result<(Self, BTreeSet<String>), toml::de::Error> {
289        let deserializer = toml::Deserializer::parse(contents)?;
290        let mut unknown = BTreeSet::new();
291        let config: DeserializedUserConfig = serde_ignored::deserialize(deserializer, |path| {
292            unknown.insert(path.to_string());
293        })?;
294        Ok((config, unknown))
295    }
296
297    /// Compiles the user config by parsing platform specs in overrides.
298    ///
299    /// The `path` is used for error reporting.
300    fn compile(self, path: &Utf8Path) -> Result<CompiledUserConfig, UserConfigError> {
301        let mut ui_overrides = Vec::with_capacity(self.overrides.len());
302        let mut record_overrides = Vec::with_capacity(self.overrides.len());
303        for (index, override_) in self.overrides.into_iter().enumerate() {
304            let platform_spec = TargetSpec::new(override_.platform).map_err(|error| {
305                UserConfigError::OverridePlatformSpec {
306                    path: path.to_owned(),
307                    index,
308                    error: Box::new(error),
309                }
310            })?;
311            // Each override entry uses the same platform spec for both UI and
312            // record settings.
313            ui_overrides.push(CompiledUiOverride::new(platform_spec.clone(), override_.ui));
314            record_overrides.push(CompiledRecordOverride::new(platform_spec, override_.record));
315        }
316
317        // Convert the experimental config table to a set of enabled features.
318        let experimental = self.experimental.to_set();
319
320        Ok(CompiledUserConfig {
321            experimental,
322            ui: self.ui,
323            record: self.record,
324            ui_overrides,
325            record_overrides,
326        })
327    }
328}
329
330/// Compiled user configuration with parsed platform specs.
331///
332/// This is created from [`DeserializedUserConfig`] after compiling platform
333/// expressions in overrides.
334#[derive(Clone, Debug)]
335pub(super) struct CompiledUserConfig {
336    /// Experimental features enabled in user config.
337    pub(super) experimental: BTreeSet<UserConfigExperimental>,
338    /// UI configuration.
339    pub(super) ui: DeserializedUiConfig,
340    /// Record configuration.
341    pub(super) record: DeserializedRecordConfig,
342    /// Compiled UI overrides with parsed platform specs.
343    pub(super) ui_overrides: Vec<CompiledUiOverride>,
344    /// Compiled record overrides with parsed platform specs.
345    pub(super) record_overrides: Vec<CompiledRecordOverride>,
346}
347
348impl CompiledUserConfig {
349    /// Loads and compiles user config from the specified location.
350    pub(super) fn from_location(
351        location: UserConfigLocation<'_>,
352    ) -> Result<Option<Self>, UserConfigError> {
353        Self::from_location_with_warnings(location, &mut DefaultUserConfigWarnings)
354    }
355
356    /// Loads and compiles user config from the specified location, with custom
357    /// warning handling.
358    fn from_location_with_warnings(
359        location: UserConfigLocation<'_>,
360        warnings: &mut impl UserConfigWarnings,
361    ) -> Result<Option<Self>, UserConfigError> {
362        match location {
363            UserConfigLocation::Isolated => {
364                debug!("user config: skipping (isolated)");
365                Ok(None)
366            }
367            UserConfigLocation::Explicit(path) => {
368                debug!("user config: loading from explicit path {path}");
369                match Self::from_path_with_warnings(path, warnings)? {
370                    Some(config) => Ok(Some(config)),
371                    None => Err(UserConfigError::FileNotFound {
372                        path: path.to_owned(),
373                    }),
374                }
375            }
376            UserConfigLocation::Default => Self::from_default_location_with_warnings(warnings),
377        }
378    }
379
380    /// Loads and compiles user config from the default location, with custom
381    /// warning handling.
382    fn from_default_location_with_warnings(
383        warnings: &mut impl UserConfigWarnings,
384    ) -> Result<Option<Self>, UserConfigError> {
385        let paths = user_config_paths()?;
386        if paths.is_empty() {
387            debug!("user config: could not determine config directory");
388            return Ok(None);
389        }
390
391        for path in &paths {
392            match Self::from_path_with_warnings(path, warnings)? {
393                Some(config) => return Ok(Some(config)),
394                None => continue,
395            }
396        }
397
398        debug!(
399            "user config: no config file found at any candidate path: {:?}",
400            paths
401        );
402        Ok(None)
403    }
404
405    /// Loads and compiles user config from a specific path with custom warning
406    /// handling.
407    fn from_path_with_warnings(
408        path: &Utf8Path,
409        warnings: &mut impl UserConfigWarnings,
410    ) -> Result<Option<Self>, UserConfigError> {
411        match DeserializedUserConfig::from_path_with_warnings(path, warnings)? {
412            Some(config) => Ok(Some(config.compile(path)?)),
413            None => Ok(None),
414        }
415    }
416}
417
418/// Deserialized form of the default user config before compilation.
419///
420/// This includes both base settings (all required) and platform-specific
421/// overrides.
422#[derive(Clone, Debug, Deserialize)]
423#[serde(rename_all = "kebab-case")]
424struct DeserializedDefaultUserConfig {
425    /// UI configuration (base settings, all required).
426    ui: DefaultUiConfig,
427
428    /// Record configuration (base settings, all required).
429    record: DefaultRecordConfig,
430
431    /// Configuration overrides.
432    #[serde(default)]
433    overrides: Vec<DeserializedOverride>,
434}
435
436/// Default user configuration parsed from the embedded TOML.
437///
438/// This contains both the base settings (all required) and compiled
439/// platform-specific overrides.
440#[derive(Clone, Debug)]
441pub(super) struct DefaultUserConfig {
442    /// Base UI configuration.
443    pub(super) ui: DefaultUiConfig,
444
445    /// Base record configuration.
446    pub(super) record: DefaultRecordConfig,
447
448    /// Compiled UI overrides with parsed platform specs.
449    pub(super) ui_overrides: Vec<CompiledUiOverride>,
450
451    /// Compiled record overrides with parsed platform specs.
452    pub(super) record_overrides: Vec<CompiledRecordOverride>,
453}
454
455impl DefaultUserConfig {
456    /// Parses and compiles the default config.
457    ///
458    /// Panics if the embedded TOML is invalid, contains unknown keys, or has
459    /// invalid platform specs in overrides.
460    pub(crate) fn from_embedded() -> Self {
461        let deserializer = toml::Deserializer::parse(UserConfig::DEFAULT_CONFIG)
462            .expect("embedded default user config should parse");
463        let mut unknown = BTreeSet::new();
464        let config: DeserializedDefaultUserConfig =
465            serde_ignored::deserialize(deserializer, |path: serde_ignored::Path| {
466                unknown.insert(path.to_string());
467            })
468            .expect("embedded default user config should be valid");
469
470        // Make sure there aren't any unknown keys in the default config, since it is
471        // embedded/shipped with this binary.
472        if !unknown.is_empty() {
473            panic!(
474                "found unknown keys in default user config: {}",
475                unknown.into_iter().collect::<Vec<_>>().join(", ")
476            );
477        }
478
479        // Compile platform specs in overrides.
480        let mut ui_overrides = Vec::with_capacity(config.overrides.len());
481        let mut record_overrides = Vec::with_capacity(config.overrides.len());
482        for (index, override_) in config.overrides.into_iter().enumerate() {
483            let platform_spec = TargetSpec::new(override_.platform).unwrap_or_else(|error| {
484                panic!(
485                    "embedded default user config has invalid platform spec \
486                     in [[overrides]] at index {index}: {error}"
487                )
488            });
489            // Each override entry uses the same platform spec for both UI and
490            // record settings.
491            ui_overrides.push(CompiledUiOverride::new(platform_spec.clone(), override_.ui));
492            record_overrides.push(CompiledRecordOverride::new(platform_spec, override_.record));
493        }
494
495        Self {
496            ui: config.ui,
497            record: config.record,
498            ui_overrides,
499            record_overrides,
500        }
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use camino::Utf8PathBuf;
508    use camino_tempfile::tempdir;
509
510    /// Test implementation of UserConfigWarnings that collects warnings for testing.
511    #[derive(Default)]
512    struct TestUserConfigWarnings {
513        unknown_keys: Option<(Utf8PathBuf, BTreeSet<String>)>,
514    }
515
516    impl UserConfigWarnings for TestUserConfigWarnings {
517        fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>) {
518            self.unknown_keys = Some((config_file.to_owned(), unknown.clone()));
519        }
520    }
521
522    #[test]
523    fn default_user_config_is_valid() {
524        // This will panic if the TOML is missing any required fields, or has
525        // unknown keys.
526        let _ = DefaultUserConfig::from_embedded();
527    }
528
529    #[test]
530    fn ignored_keys() {
531        let config_contents = r#"
532        ignored1 = "test"
533
534        [ui]
535        show-progress = "bar"
536        ignored2 = "hi"
537        "#;
538
539        let temp_dir = tempdir().unwrap();
540        let config_path = temp_dir.path().join("config.toml");
541        std::fs::write(&config_path, config_contents).unwrap();
542
543        let mut warnings = TestUserConfigWarnings::default();
544        let config = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings)
545            .expect("config valid");
546
547        assert!(config.is_some(), "config should be loaded");
548        let config = config.unwrap();
549        assert!(
550            matches!(
551                config.ui.show_progress,
552                Some(crate::user_config::elements::UiShowProgress::Bar)
553            ),
554            "show-progress should be parsed correctly"
555        );
556
557        let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
558        assert_eq!(path, config_path, "path should match");
559        assert_eq!(
560            unknown,
561            maplit::btreeset! {
562                "ignored1".to_owned(),
563                "ui.ignored2".to_owned(),
564            },
565            "unknown keys should be detected"
566        );
567    }
568
569    #[test]
570    fn no_ignored_keys() {
571        let config_contents = r#"
572        [ui]
573        show-progress = "counter"
574        max-progress-running = 10
575        input-handler = false
576        output-indent = true
577        "#;
578
579        let temp_dir = tempdir().unwrap();
580        let config_path = temp_dir.path().join("config.toml");
581        std::fs::write(&config_path, config_contents).unwrap();
582
583        let mut warnings = TestUserConfigWarnings::default();
584        let config = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings)
585            .expect("config valid");
586
587        assert!(config.is_some(), "config should be loaded");
588        assert!(
589            warnings.unknown_keys.is_none(),
590            "no unknown keys should be detected"
591        );
592    }
593
594    #[test]
595    fn overrides_parsing() {
596        let config_contents = r#"
597        [ui]
598        show-progress = "bar"
599
600        [[overrides]]
601        platform = "cfg(windows)"
602        ui.show-progress = "counter"
603        ui.max-progress-running = 4
604
605        [[overrides]]
606        platform = "cfg(unix)"
607        ui.input-handler = false
608        "#;
609
610        let temp_dir = tempdir().unwrap();
611        let config_path = temp_dir.path().join("config.toml");
612        std::fs::write(&config_path, config_contents).unwrap();
613
614        let mut warnings = TestUserConfigWarnings::default();
615        let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
616            .expect("config valid")
617            .expect("config should exist");
618
619        assert!(
620            warnings.unknown_keys.is_none(),
621            "no unknown keys should be detected"
622        );
623        assert_eq!(config.ui_overrides.len(), 2, "should have 2 UI overrides");
624        assert_eq!(
625            config.record_overrides.len(),
626            2,
627            "should have 2 record overrides"
628        );
629    }
630
631    #[test]
632    fn overrides_record_parsing() {
633        let config_contents = r#"
634        [record]
635        enabled = false
636
637        [[overrides]]
638        platform = "cfg(unix)"
639        record.enabled = true
640        record.max-output-size = "50MB"
641
642        [[overrides]]
643        platform = "cfg(windows)"
644        record.enabled = true
645        record.max-records = 200
646        "#;
647
648        let temp_dir = tempdir().unwrap();
649        let config_path = temp_dir.path().join("config.toml");
650        std::fs::write(&config_path, config_contents).unwrap();
651
652        let mut warnings = TestUserConfigWarnings::default();
653        let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
654            .expect("config valid")
655            .expect("config should exist");
656
657        assert!(
658            warnings.unknown_keys.is_none(),
659            "no unknown keys should be detected"
660        );
661        assert_eq!(
662            config.record_overrides.len(),
663            2,
664            "should have 2 record overrides"
665        );
666    }
667
668    #[test]
669    fn overrides_record_unknown_key() {
670        let config_contents = r#"
671        [[overrides]]
672        platform = "cfg(unix)"
673        record.enabled = true
674        record.unknown-key = "test"
675        "#;
676
677        let temp_dir = tempdir().unwrap();
678        let config_path = temp_dir.path().join("config.toml");
679        std::fs::write(&config_path, config_contents).unwrap();
680
681        let mut warnings = TestUserConfigWarnings::default();
682        let _config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
683            .expect("config valid")
684            .expect("config should exist");
685
686        let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
687        assert_eq!(path, config_path, "path should match");
688        assert!(
689            unknown.contains("overrides.0.record.unknown-key"),
690            "unknown key should be detected: {unknown:?}"
691        );
692    }
693
694    #[test]
695    fn overrides_invalid_platform() {
696        let config_contents = r#"
697        [ui]
698        show-progress = "bar"
699
700        [[overrides]]
701        platform = "invalid platform spec!!!"
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 = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings);
711
712        assert!(
713            matches!(
714                result,
715                Err(UserConfigError::OverridePlatformSpec { index: 0, .. })
716            ),
717            "should fail with platform spec error at index 0"
718        );
719    }
720
721    #[test]
722    fn overrides_missing_platform() {
723        let config_contents = r#"
724        [ui]
725        show-progress = "bar"
726
727        [[overrides]]
728        # platform field is missing - should fail to parse
729        ui.show-progress = "counter"
730        "#;
731
732        let temp_dir = tempdir().unwrap();
733        let config_path = temp_dir.path().join("config.toml");
734        std::fs::write(&config_path, config_contents).unwrap();
735
736        let mut warnings = TestUserConfigWarnings::default();
737        let result = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings);
738
739        assert!(
740            matches!(result, Err(UserConfigError::Parse { .. })),
741            "should fail with parse error due to missing required platform field: {result:?}"
742        );
743    }
744
745    #[test]
746    fn experimental_features_parsing() {
747        let config_contents = r#"
748        [experimental]
749        record = true
750
751        [ui]
752        show-progress = "bar"
753        "#;
754
755        let temp_dir = tempdir().unwrap();
756        let config_path = temp_dir.path().join("config.toml");
757        std::fs::write(&config_path, config_contents).unwrap();
758
759        let mut warnings = TestUserConfigWarnings::default();
760        let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
761            .expect("config valid")
762            .expect("config should exist");
763
764        assert!(
765            warnings.unknown_keys.is_none(),
766            "no unknown keys should be detected"
767        );
768        assert!(
769            config
770                .experimental
771                .contains(&UserConfigExperimental::Record),
772            "record feature should be enabled"
773        );
774    }
775
776    #[test]
777    fn experimental_features_disabled() {
778        let config_contents = r#"
779        [experimental]
780        record = false
781
782        [ui]
783        show-progress = "bar"
784        "#;
785
786        let temp_dir = tempdir().unwrap();
787        let config_path = temp_dir.path().join("config.toml");
788        std::fs::write(&config_path, config_contents).unwrap();
789
790        let mut warnings = TestUserConfigWarnings::default();
791        let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
792            .expect("config valid")
793            .expect("config should exist");
794
795        assert!(
796            warnings.unknown_keys.is_none(),
797            "no unknown keys should be detected"
798        );
799        assert!(
800            !config
801                .experimental
802                .contains(&UserConfigExperimental::Record),
803            "record feature should not be enabled"
804        );
805    }
806
807    #[test]
808    fn experimental_features_unknown_warning() {
809        let config_contents = r#"
810        [experimental]
811        record = true
812        unknown-feature = true
813
814        [ui]
815        show-progress = "bar"
816        "#;
817
818        let temp_dir = tempdir().unwrap();
819        let config_path = temp_dir.path().join("config.toml");
820        std::fs::write(&config_path, config_contents).unwrap();
821
822        let mut warnings = TestUserConfigWarnings::default();
823        let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
824            .expect("config valid")
825            .expect("config should exist");
826
827        // Unknown fields should be warnings, not errors.
828        let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
829        assert_eq!(path, config_path, "path should match");
830        assert!(
831            unknown.contains("experimental.unknown-feature"),
832            "unknown key should be detected: {unknown:?}"
833        );
834
835        // The known feature should still be enabled.
836        assert!(
837            config
838                .experimental
839                .contains(&UserConfigExperimental::Record),
840            "record feature should be enabled"
841        );
842    }
843}