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