nextest_runner/user_config/
imp.rs1use 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#[derive(Clone, Debug)]
22pub struct UserConfig {
23 pub ui: UiConfig,
25}
26
27impl UserConfig {
28 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
48trait UserConfigWarnings {
53 fn unknown_config_keys(&mut self, config_file: &Utf8Path, unknown: &BTreeSet<String>);
55}
56
57struct 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 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#[derive(Clone, Debug, Default, Deserialize)]
92#[serde(rename_all = "kebab-case")]
93struct DeserializedUserConfig {
94 #[serde(default)]
96 ui: DeserializedUiConfig,
97
98 #[serde(default)]
100 overrides: Vec<DeserializedOverride>,
101}
102
103#[derive(Clone, Debug, Deserialize)]
108#[serde(rename_all = "kebab-case")]
109struct DeserializedOverride {
110 platform: String,
115
116 #[serde(default)]
118 ui: DeserializedUiOverrideData,
119}
120
121impl DeserializedUserConfig {
122 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 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 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#[derive(Clone, Debug)]
197pub(super) struct CompiledUserConfig {
198 pub(super) ui: DeserializedUiConfig,
200 pub(super) ui_overrides: Vec<CompiledUiOverride>,
202}
203
204impl CompiledUserConfig {
205 pub(super) fn from_default_location() -> Result<Option<Self>, UserConfigError> {
215 Self::from_default_location_with_warnings(&mut DefaultUserConfigWarnings)
216 }
217
218 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 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#[derive(Clone, Debug, Deserialize)]
261#[serde(rename_all = "kebab-case")]
262struct DeserializedDefaultUserConfig {
263 ui: DefaultUiConfig,
265
266 #[serde(default)]
268 overrides: Vec<DeserializedOverride>,
269}
270
271#[derive(Clone, Debug)]
276pub(super) struct DefaultUserConfig {
277 pub(super) ui: DefaultUiConfig,
279
280 pub(super) ui_overrides: Vec<CompiledUiOverride>,
282}
283
284impl DefaultUserConfig {
285 const DEFAULT_CONFIG: &'static str = include_str!("../../default-user-config.toml");
287
288 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 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 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 #[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 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}