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::{DefaultUiConfig, UiConfig},
9};
10use crate::errors::UserConfigError;
11use camino::Utf8Path;
12use serde::Deserialize;
13use std::collections::BTreeSet;
14use tracing::{debug, warn};
15
16/// Trait for handling user configuration warnings.
17///
18/// This trait allows for different warning handling strategies, such as logging
19/// warnings (the default behavior) or collecting them for testing purposes.
20trait UserConfigWarnings {
21    /// Handle unknown configuration keys found in a user config file.
22    fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>);
23}
24
25/// Default implementation of UserConfigWarnings that logs warnings using the
26/// tracing crate.
27struct DefaultUserConfigWarnings;
28
29impl UserConfigWarnings for DefaultUserConfigWarnings {
30    fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>) {
31        let mut unknown_str = String::new();
32        if unknown.len() == 1 {
33            // Print this on the same line.
34            unknown_str.push_str("key: ");
35            unknown_str.push_str(unknown.iter().next().unwrap());
36        } else {
37            unknown_str.push_str("keys:\n");
38            for ignored_key in unknown {
39                unknown_str.push('\n');
40                unknown_str.push_str("  - ");
41                unknown_str.push_str(ignored_key);
42            }
43        }
44
45        warn!(
46            "in user config file {}, ignoring unknown configuration {unknown_str}",
47            config_file,
48        );
49    }
50}
51
52/// User-specific configuration.
53///
54/// This configuration is loaded from the user's config directory and contains
55/// personal preferences that shouldn't be version-controlled.
56#[derive(Clone, Debug, Default, Deserialize)]
57#[serde(rename_all = "kebab-case")]
58pub struct UserConfig {
59    /// UI configuration.
60    #[serde(default)]
61    pub ui: UiConfig,
62}
63
64impl UserConfig {
65    /// Loads user config from the default location.
66    ///
67    /// Tries candidate paths in order and returns the first config file found:
68    /// - Unix/macOS: `~/.config/nextest/config.toml`
69    /// - Windows: `%APPDATA%\nextest\config.toml`, then `~/.config/nextest/config.toml`
70    ///
71    /// Returns `Ok(None)` if no config file exists at any candidate path.
72    /// Returns `Err` if a config file exists but is invalid.
73    pub fn from_default_location() -> Result<Option<Self>, UserConfigError> {
74        Self::from_default_location_with_warnings(&mut DefaultUserConfigWarnings)
75    }
76
77    /// Loads user config from the default location, with custom warning
78    /// handling.
79    fn from_default_location_with_warnings(
80        warnings: &mut impl UserConfigWarnings,
81    ) -> Result<Option<Self>, UserConfigError> {
82        let paths = user_config_paths()?;
83        if paths.is_empty() {
84            debug!("user config: could not determine config directory");
85            return Ok(None);
86        }
87
88        for path in &paths {
89            match Self::from_path_with_warnings(path, warnings)? {
90                Some(config) => return Ok(Some(config)),
91                None => continue,
92            }
93        }
94
95        debug!(
96            "user config: no config file found at any candidate path: {:?}",
97            paths
98        );
99        Ok(None)
100    }
101
102    /// Loads user config from a specific path.
103    ///
104    /// Returns `Ok(None)` if the file does not exist.
105    /// Returns `Err` if the file exists but cannot be read or parsed.
106    pub fn from_path(path: &Utf8Path) -> Result<Option<Self>, UserConfigError> {
107        Self::from_path_with_warnings(path, &mut DefaultUserConfigWarnings)
108    }
109
110    /// Loads user config from a specific path with custom warning handling.
111    ///
112    /// Returns `Ok(None)` if the file does not exist.
113    /// Returns `Err` if the file exists but cannot be read or parsed.
114    fn from_path_with_warnings(
115        path: &Utf8Path,
116        warnings: &mut impl UserConfigWarnings,
117    ) -> Result<Option<Self>, UserConfigError> {
118        debug!("user config: attempting to load from {path}");
119        let contents = match std::fs::read_to_string(path) {
120            Ok(contents) => contents,
121            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
122                debug!("user config: file does not exist at {path}");
123                return Ok(None);
124            }
125            Err(error) => {
126                return Err(UserConfigError::Read {
127                    path: path.to_owned(),
128                    error,
129                });
130            }
131        };
132
133        let (config, unknown) =
134            Self::deserialize_toml(&contents).map_err(|error| UserConfigError::Parse {
135                path: path.to_owned(),
136                error,
137            })?;
138
139        if !unknown.is_empty() {
140            warnings.unknown_config_keys(path, &unknown);
141        }
142
143        debug!("user config: loaded successfully from {path}");
144        Ok(Some(config))
145    }
146
147    /// Deserializes TOML content and returns the config along with any unknown keys.
148    fn deserialize_toml(contents: &str) -> Result<(Self, BTreeSet<String>), toml::de::Error> {
149        let deserializer = toml::Deserializer::parse(contents)?;
150        let mut unknown = BTreeSet::new();
151        let config: UserConfig = serde_ignored::deserialize(deserializer, |path| {
152            unknown.insert(path.to_string());
153        })?;
154        Ok((config, unknown))
155    }
156}
157
158/// Default user configuration parsed from the embedded TOML.
159///
160/// All fields are required - this ensures the default config is complete.
161#[derive(Clone, Debug, Deserialize)]
162#[serde(rename_all = "kebab-case")]
163pub struct DefaultUserConfig {
164    /// UI configuration.
165    pub ui: DefaultUiConfig,
166}
167
168impl DefaultUserConfig {
169    /// The embedded default user config TOML.
170    pub const DEFAULT_CONFIG: &'static str = include_str!("../../default-user-config.toml");
171
172    /// Parses the default config.
173    ///
174    /// Panics if the embedded TOML is invalid or contains unknown keys.
175    pub fn from_embedded() -> Self {
176        let deserializer = toml::Deserializer::parse(Self::DEFAULT_CONFIG)
177            .expect("embedded default user config should parse");
178        let mut unknown = BTreeSet::new();
179        let config: DefaultUserConfig =
180            serde_ignored::deserialize(deserializer, |path: serde_ignored::Path| {
181                unknown.insert(path.to_string());
182            })
183            .expect("embedded default user config should be valid");
184
185        // Make sure there aren't any unknown keys in the default config, since it is
186        // embedded/shipped with this binary.
187        if !unknown.is_empty() {
188            panic!(
189                "found unknown keys in default user config: {}",
190                unknown.into_iter().collect::<Vec<_>>().join(", ")
191            );
192        }
193
194        config
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use camino::Utf8PathBuf;
202    use camino_tempfile::tempdir;
203
204    /// Test implementation of UserConfigWarnings that collects warnings for testing.
205    #[derive(Default)]
206    struct TestUserConfigWarnings {
207        unknown_keys: Option<(Utf8PathBuf, BTreeSet<String>)>,
208    }
209
210    impl UserConfigWarnings for TestUserConfigWarnings {
211        fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>) {
212            self.unknown_keys = Some((config_file.to_owned(), unknown.clone()));
213        }
214    }
215
216    #[test]
217    fn default_user_config_is_valid() {
218        // This will panic if the TOML is missing any required fields, or has
219        // unknown keys.
220        let _ = DefaultUserConfig::from_embedded();
221    }
222
223    #[test]
224    fn ignored_keys() {
225        let config_contents = r#"
226        ignored1 = "test"
227
228        [ui]
229        show-progress = "bar"
230        ignored2 = "hi"
231        "#;
232
233        let temp_dir = tempdir().unwrap();
234        let config_path = temp_dir.path().join("config.toml");
235        std::fs::write(&config_path, config_contents).unwrap();
236
237        let mut warnings = TestUserConfigWarnings::default();
238        let config =
239            UserConfig::from_path_with_warnings(&config_path, &mut warnings).expect("config valid");
240
241        assert!(config.is_some(), "config should be loaded");
242        let config = config.unwrap();
243        assert!(
244            matches!(
245                config.ui.show_progress,
246                Some(crate::user_config::elements::UiShowProgress::Bar)
247            ),
248            "show-progress should be parsed correctly"
249        );
250
251        let (path, unknown) = warnings.unknown_keys.expect("should have unknown keys");
252        assert_eq!(path, config_path, "path should match");
253        assert_eq!(
254            unknown,
255            maplit::btreeset! {
256                "ignored1".to_owned(),
257                "ui.ignored2".to_owned(),
258            },
259            "unknown keys should be detected"
260        );
261    }
262
263    #[test]
264    fn no_ignored_keys() {
265        let config_contents = r#"
266        [ui]
267        show-progress = "counter"
268        max-progress-running = 10
269        input-handler = false
270        output-indent = true
271        "#;
272
273        let temp_dir = tempdir().unwrap();
274        let config_path = temp_dir.path().join("config.toml");
275        std::fs::write(&config_path, config_contents).unwrap();
276
277        let mut warnings = TestUserConfigWarnings::default();
278        let config =
279            UserConfig::from_path_with_warnings(&config_path, &mut warnings).expect("config valid");
280
281        assert!(config.is_some(), "config should be loaded");
282        assert!(
283            warnings.unknown_keys.is_none(),
284            "no unknown keys should be detected"
285        );
286    }
287}