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