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        CompiledUiOverride, DefaultUiConfig, DeserializedUiConfig, DeserializedUiOverrideData,
10        UiConfig,
11    },
12};
13use crate::errors::UserConfigError;
14use camino::Utf8Path;
15use serde::Deserialize;
16use std::{collections::BTreeSet, io};
17use target_spec::{Platform, TargetSpec};
18use tracing::{debug, warn};
19
20/// User configuration after custom settings and overrides have been applied.
21#[derive(Clone, Debug)]
22pub struct UserConfig {
23    /// Resolved UI configuration.
24    pub ui: UiConfig,
25}
26
27impl UserConfig {
28    /// Loads and resolves user configuration for the given host platform.
29    pub fn for_host_platform(host_platform: &Platform) -> Result<Self, UserConfigError> {
30        let user_config = CompiledUserConfig::from_default_location()?;
31        let default_user_config = DefaultUserConfig::from_embedded();
32
33        let resolved_ui = UiConfig::resolve(
34            &default_user_config.ui,
35            &default_user_config.ui_overrides,
36            user_config.as_ref().map(|c| &c.ui),
37            user_config
38                .as_ref()
39                .map(|c| &c.ui_overrides[..])
40                .unwrap_or(&[]),
41            host_platform,
42        );
43
44        Ok(Self { ui: resolved_ui })
45    }
46}
47
48/// Trait for handling user configuration warnings.
49///
50/// This trait allows for different warning handling strategies, such as logging
51/// warnings (the default behavior) or collecting them for testing purposes.
52trait UserConfigWarnings {
53    /// Handle unknown configuration keys found in a user config file.
54    fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>);
55}
56
57/// Default implementation of UserConfigWarnings that logs warnings using the
58/// tracing crate.
59struct DefaultUserConfigWarnings;
60
61impl UserConfigWarnings for DefaultUserConfigWarnings {
62    fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>) {
63        let mut unknown_str = String::new();
64        if unknown.len() == 1 {
65            // Print this on the same line.
66            unknown_str.push_str("key: ");
67            unknown_str.push_str(unknown.iter().next().unwrap());
68        } else {
69            unknown_str.push_str("keys:\n");
70            for ignored_key in unknown {
71                unknown_str.push('\n');
72                unknown_str.push_str("  - ");
73                unknown_str.push_str(ignored_key);
74            }
75        }
76
77        warn!(
78            "in user config file {}, ignoring unknown configuration {unknown_str}",
79            config_file,
80        );
81    }
82}
83
84/// User-specific configuration (deserialized form).
85///
86/// This configuration is loaded from the user's config directory and contains
87/// personal preferences that shouldn't be version-controlled.
88///
89/// Use [`DeserializedUserConfig::compile`] to compile platform specs and get a
90/// [`CompiledUserConfig`].
91#[derive(Clone, Debug, Default, Deserialize)]
92#[serde(rename_all = "kebab-case")]
93struct DeserializedUserConfig {
94    /// UI configuration.
95    #[serde(default)]
96    ui: DeserializedUiConfig,
97
98    /// Configuration overrides.
99    #[serde(default)]
100    overrides: Vec<DeserializedOverride>,
101}
102
103/// Deserialized form of a single override entry.
104///
105/// Each override has a platform filter and optional settings for different
106/// configuration sections.
107#[derive(Clone, Debug, Deserialize)]
108#[serde(rename_all = "kebab-case")]
109struct DeserializedOverride {
110    /// Platform to match (required).
111    ///
112    /// This is a target-spec expression like `cfg(windows)` or
113    /// `x86_64-unknown-linux-gnu`.
114    platform: String,
115
116    /// UI settings to override.
117    #[serde(default)]
118    ui: DeserializedUiOverrideData,
119}
120
121impl DeserializedUserConfig {
122    /// Loads user config from a specific path with custom warning handling.
123    ///
124    /// Returns `Ok(None)` if the file does not exist.
125    /// Returns `Err` if the file exists but cannot be read or parsed.
126    fn from_path_with_warnings(
127        path: &Utf8Path,
128        warnings: &mut impl UserConfigWarnings,
129    ) -> Result<Option<Self>, UserConfigError> {
130        debug!("user config: attempting to load from {path}");
131        let contents = match std::fs::read_to_string(path) {
132            Ok(contents) => contents,
133            Err(error) if error.kind() == io::ErrorKind::NotFound => {
134                debug!("user config: file does not exist at {path}");
135                return Ok(None);
136            }
137            Err(error) => {
138                return Err(UserConfigError::Read {
139                    path: path.to_owned(),
140                    error,
141                });
142            }
143        };
144
145        let (config, unknown) =
146            Self::deserialize_toml(&contents).map_err(|error| UserConfigError::Parse {
147                path: path.to_owned(),
148                error,
149            })?;
150
151        if !unknown.is_empty() {
152            warnings.unknown_config_keys(path, &unknown);
153        }
154
155        debug!("user config: loaded successfully from {path}");
156        Ok(Some(config))
157    }
158
159    /// Deserializes TOML content and returns the config along with any unknown keys.
160    fn deserialize_toml(contents: &str) -> Result<(Self, BTreeSet<String>), toml::de::Error> {
161        let deserializer = toml::Deserializer::parse(contents)?;
162        let mut unknown = BTreeSet::new();
163        let config: DeserializedUserConfig = serde_ignored::deserialize(deserializer, |path| {
164            unknown.insert(path.to_string());
165        })?;
166        Ok((config, unknown))
167    }
168
169    /// Compiles the user config by parsing platform specs in overrides.
170    ///
171    /// The `path` is used for error reporting.
172    fn compile(self, path: &Utf8Path) -> Result<CompiledUserConfig, UserConfigError> {
173        let mut ui_overrides = Vec::with_capacity(self.overrides.len());
174        for (index, override_) in self.overrides.into_iter().enumerate() {
175            let platform_spec = TargetSpec::new(override_.platform).map_err(|error| {
176                UserConfigError::OverridePlatformSpec {
177                    path: path.to_owned(),
178                    index,
179                    error,
180                }
181            })?;
182            ui_overrides.push(CompiledUiOverride::new(platform_spec, override_.ui));
183        }
184
185        Ok(CompiledUserConfig {
186            ui: self.ui,
187            ui_overrides,
188        })
189    }
190}
191
192/// Compiled user configuration with parsed platform specs.
193///
194/// This is created from [`DeserializedUserConfig`] after compiling platform
195/// expressions in overrides.
196#[derive(Clone, Debug)]
197pub(super) struct CompiledUserConfig {
198    /// UI configuration.
199    pub(super) ui: DeserializedUiConfig,
200    /// Compiled UI overrides with parsed platform specs.
201    pub(super) ui_overrides: Vec<CompiledUiOverride>,
202}
203
204impl CompiledUserConfig {
205    /// Loads and compiles user config from the default location.
206    ///
207    /// This is a convenience method that combines loading and compilation.
208    /// Platform specs in overrides are compiled and validated.
209    ///
210    /// Returns `Ok(None)` if no config file exists at any candidate path.
211    /// Returns `Err` if:
212    /// - A config file exists but cannot be read or parsed.
213    /// - A platform spec in an override is invalid.
214    pub(super) fn from_default_location() -> Result<Option<Self>, UserConfigError> {
215        Self::from_default_location_with_warnings(&mut DefaultUserConfigWarnings)
216    }
217
218    /// Loads and compiles user config from the default location, with custom
219    /// warning handling.
220    fn from_default_location_with_warnings(
221        warnings: &mut impl UserConfigWarnings,
222    ) -> Result<Option<Self>, UserConfigError> {
223        let paths = user_config_paths()?;
224        if paths.is_empty() {
225            debug!("user config: could not determine config directory");
226            return Ok(None);
227        }
228
229        for path in &paths {
230            match Self::from_path_with_warnings(path, warnings)? {
231                Some(config) => return Ok(Some(config)),
232                None => continue,
233            }
234        }
235
236        debug!(
237            "user config: no config file found at any candidate path: {:?}",
238            paths
239        );
240        Ok(None)
241    }
242
243    /// Loads and compiles user config from a specific path with custom warning
244    /// handling.
245    fn from_path_with_warnings(
246        path: &Utf8Path,
247        warnings: &mut impl UserConfigWarnings,
248    ) -> Result<Option<Self>, UserConfigError> {
249        match DeserializedUserConfig::from_path_with_warnings(path, warnings)? {
250            Some(config) => Ok(Some(config.compile(path)?)),
251            None => Ok(None),
252        }
253    }
254}
255
256/// Deserialized form of the default user config before compilation.
257///
258/// This includes both base settings (all required) and platform-specific
259/// overrides.
260#[derive(Clone, Debug, Deserialize)]
261#[serde(rename_all = "kebab-case")]
262struct DeserializedDefaultUserConfig {
263    /// UI configuration (base settings, all required).
264    ui: DefaultUiConfig,
265
266    /// Configuration overrides.
267    #[serde(default)]
268    overrides: Vec<DeserializedOverride>,
269}
270
271/// Default user configuration parsed from the embedded TOML.
272///
273/// This contains both the base settings (all required) and compiled
274/// platform-specific overrides.
275#[derive(Clone, Debug)]
276pub(super) struct DefaultUserConfig {
277    /// Base UI configuration.
278    pub(super) ui: DefaultUiConfig,
279
280    /// Compiled UI overrides with parsed platform specs.
281    pub(super) ui_overrides: Vec<CompiledUiOverride>,
282}
283
284impl DefaultUserConfig {
285    /// The embedded default user config TOML.
286    const DEFAULT_CONFIG: &'static str = include_str!("../../default-user-config.toml");
287
288    /// Parses and compiles the default config.
289    ///
290    /// Panics if the embedded TOML is invalid, contains unknown keys, or has
291    /// invalid platform specs in overrides.
292    pub(crate) fn from_embedded() -> Self {
293        let deserializer = toml::Deserializer::parse(Self::DEFAULT_CONFIG)
294            .expect("embedded default user config should parse");
295        let mut unknown = BTreeSet::new();
296        let config: DeserializedDefaultUserConfig =
297            serde_ignored::deserialize(deserializer, |path: serde_ignored::Path| {
298                unknown.insert(path.to_string());
299            })
300            .expect("embedded default user config should be valid");
301
302        // Make sure there aren't any unknown keys in the default config, since it is
303        // embedded/shipped with this binary.
304        if !unknown.is_empty() {
305            panic!(
306                "found unknown keys in default user config: {}",
307                unknown.into_iter().collect::<Vec<_>>().join(", ")
308            );
309        }
310
311        // Compile platform specs in overrides.
312        let ui_overrides: Vec<CompiledUiOverride> = config
313            .overrides
314            .into_iter()
315            .enumerate()
316            .map(|(index, override_)| {
317                let platform_spec = TargetSpec::new(override_.platform).unwrap_or_else(|error| {
318                    panic!(
319                        "embedded default user config has invalid platform spec \
320                         in [[overrides]] at index {index}: {error}"
321                    )
322                });
323                CompiledUiOverride::new(platform_spec, override_.ui)
324            })
325            .collect();
326
327        Self {
328            ui: config.ui,
329            ui_overrides,
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use camino::Utf8PathBuf;
338    use camino_tempfile::tempdir;
339
340    /// Test implementation of UserConfigWarnings that collects warnings for testing.
341    #[derive(Default)]
342    struct TestUserConfigWarnings {
343        unknown_keys: Option<(Utf8PathBuf, BTreeSet<String>)>,
344    }
345
346    impl UserConfigWarnings for TestUserConfigWarnings {
347        fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>) {
348            self.unknown_keys = Some((config_file.to_owned(), unknown.clone()));
349        }
350    }
351
352    #[test]
353    fn default_user_config_is_valid() {
354        // This will panic if the TOML is missing any required fields, or has
355        // unknown keys.
356        let _ = DefaultUserConfig::from_embedded();
357    }
358
359    #[test]
360    fn ignored_keys() {
361        let config_contents = r#"
362        ignored1 = "test"
363
364        [ui]
365        show-progress = "bar"
366        ignored2 = "hi"
367        "#;
368
369        let temp_dir = tempdir().unwrap();
370        let config_path = temp_dir.path().join("config.toml");
371        std::fs::write(&config_path, config_contents).unwrap();
372
373        let mut warnings = TestUserConfigWarnings::default();
374        let config = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings)
375            .expect("config valid");
376
377        assert!(config.is_some(), "config should be loaded");
378        let config = config.unwrap();
379        assert!(
380            matches!(
381                config.ui.show_progress,
382                Some(crate::user_config::elements::UiShowProgress::Bar)
383            ),
384            "show-progress should be parsed correctly"
385        );
386
387        let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
388        assert_eq!(path, config_path, "path should match");
389        assert_eq!(
390            unknown,
391            maplit::btreeset! {
392                "ignored1".to_owned(),
393                "ui.ignored2".to_owned(),
394            },
395            "unknown keys should be detected"
396        );
397    }
398
399    #[test]
400    fn no_ignored_keys() {
401        let config_contents = r#"
402        [ui]
403        show-progress = "counter"
404        max-progress-running = 10
405        input-handler = false
406        output-indent = true
407        "#;
408
409        let temp_dir = tempdir().unwrap();
410        let config_path = temp_dir.path().join("config.toml");
411        std::fs::write(&config_path, config_contents).unwrap();
412
413        let mut warnings = TestUserConfigWarnings::default();
414        let config = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings)
415            .expect("config valid");
416
417        assert!(config.is_some(), "config should be loaded");
418        assert!(
419            warnings.unknown_keys.is_none(),
420            "no unknown keys should be detected"
421        );
422    }
423
424    #[test]
425    fn overrides_parsing() {
426        let config_contents = r#"
427        [ui]
428        show-progress = "bar"
429
430        [[overrides]]
431        platform = "cfg(windows)"
432        ui.show-progress = "counter"
433        ui.max-progress-running = 4
434
435        [[overrides]]
436        platform = "cfg(unix)"
437        ui.input-handler = false
438        "#;
439
440        let temp_dir = tempdir().unwrap();
441        let config_path = temp_dir.path().join("config.toml");
442        std::fs::write(&config_path, config_contents).unwrap();
443
444        let mut warnings = TestUserConfigWarnings::default();
445        let config = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings)
446            .expect("config valid")
447            .expect("config should exist");
448
449        assert!(
450            warnings.unknown_keys.is_none(),
451            "no unknown keys should be detected"
452        );
453        assert_eq!(config.ui_overrides.len(), 2, "should have 2 overrides");
454    }
455
456    #[test]
457    fn overrides_invalid_platform() {
458        let config_contents = r#"
459        [ui]
460        show-progress = "bar"
461
462        [[overrides]]
463        platform = "invalid platform spec!!!"
464        ui.show-progress = "counter"
465        "#;
466
467        let temp_dir = tempdir().unwrap();
468        let config_path = temp_dir.path().join("config.toml");
469        std::fs::write(&config_path, config_contents).unwrap();
470
471        let mut warnings = TestUserConfigWarnings::default();
472        let result = CompiledUserConfig::from_path_with_warnings(&config_path, &mut warnings);
473
474        assert!(
475            matches!(
476                result,
477                Err(UserConfigError::OverridePlatformSpec { index: 0, .. })
478            ),
479            "should fail with platform spec error at index 0"
480        );
481    }
482
483    #[test]
484    fn overrides_missing_platform() {
485        let config_contents = r#"
486        [ui]
487        show-progress = "bar"
488
489        [[overrides]]
490        # platform field is missing - should fail to parse
491        ui.show-progress = "counter"
492        "#;
493
494        let temp_dir = tempdir().unwrap();
495        let config_path = temp_dir.path().join("config.toml");
496        std::fs::write(&config_path, config_contents).unwrap();
497
498        let mut warnings = TestUserConfigWarnings::default();
499        let result = DeserializedUserConfig::from_path_with_warnings(&config_path, &mut warnings);
500
501        assert!(
502            matches!(result, Err(UserConfigError::Parse { .. })),
503            "should fail with parse error due to missing required platform field: {result:?}"
504        );
505    }
506}