Skip to main content

nextest_runner/user_config/
early.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Early user configuration loading for pager settings.
5//!
6//! This module provides minimal configuration loading for use before full CLI
7//! parsing is complete.
8//!
9//! Following the pattern of [`crate::config::core::VersionOnlyConfig`], this
10//! loads only the fields needed for early decisions, with graceful fallback
11//! to defaults on any errors.
12
13use super::{
14    discovery::user_config_paths,
15    elements::{
16        CompiledUiOverride, DeserializedUiOverrideData, PagerSetting, PaginateSetting,
17        StreampagerConfig, StreampagerInterface, StreampagerWrapping,
18    },
19    helpers::resolve_ui_setting,
20    imp::{DefaultUserConfig, UserConfigLocation},
21};
22use camino::{Utf8Path, Utf8PathBuf};
23use serde::Deserialize;
24use std::{fmt, io};
25use target_spec::{Platform, TargetSpec};
26use tracing::{debug, warn};
27
28/// Early user configuration for pager settings.
29///
30/// This is a minimal subset of user configuration loaded before full CLI
31/// parsing completes. It contains only the settings needed to decide whether
32/// and how to page help output.
33///
34/// Use [`Self::for_platform`] to load from the default location. If an error
35/// occurs, defaults are used and a warning is logged.
36#[derive(Clone, Debug)]
37pub struct EarlyUserConfig {
38    /// Which pager to use.
39    pub pager: PagerSetting,
40    /// When to paginate.
41    pub paginate: PaginateSetting,
42    /// Streampager configuration (for builtin pager).
43    pub streampager: StreampagerConfig,
44}
45
46impl EarlyUserConfig {
47    /// Loads early user configuration for the given host platform.
48    ///
49    /// This attempts to load user config from the specified location and resolve
50    /// pager settings. On any error, returns defaults and logs a warning.
51    ///
52    /// This is intentionally fault-tolerant: help paging is a nice-to-have
53    /// feature, so we prefer degraded behavior over failing to show help.
54    pub fn for_platform(host_platform: &Platform, location: UserConfigLocation<'_>) -> Self {
55        match Self::try_load(host_platform, location) {
56            Ok(config) => config,
57            Err(error) => {
58                warn!(
59                    "failed to load user config for pager settings, using defaults: {}",
60                    error
61                );
62                Self::defaults(host_platform)
63            }
64        }
65    }
66
67    /// Returns the default pager configuration for the host platform.
68    fn defaults(host_platform: &Platform) -> Self {
69        let default_config = DefaultUserConfig::from_embedded();
70        Self::resolve_from_defaults(&default_config, host_platform)
71    }
72
73    /// Attempts to load early user configuration from the specified location.
74    fn try_load(
75        host_platform: &Platform,
76        location: UserConfigLocation<'_>,
77    ) -> Result<Self, EarlyConfigError> {
78        let default_config = DefaultUserConfig::from_embedded();
79
80        match location {
81            UserConfigLocation::Isolated => {
82                debug!("early user config: skipping (isolated)");
83                Ok(Self::resolve_from_defaults(&default_config, host_platform))
84            }
85            UserConfigLocation::Explicit(path) => {
86                debug!("early user config: loading from explicit path {path}");
87                match EarlyDeserializedConfig::from_path(path) {
88                    Ok(Some(user_config)) => {
89                        debug!("early user config: loaded from {path}");
90                        Ok(Self::resolve(
91                            &default_config,
92                            Some(&user_config),
93                            host_platform,
94                        ))
95                    }
96                    Ok(None) => Err(EarlyConfigError::FileNotFound(path.to_owned())),
97                    Err(error) => Err(error),
98                }
99            }
100            UserConfigLocation::Default => {
101                Self::try_load_from_default_locations(&default_config, host_platform)
102            }
103        }
104    }
105
106    /// Attempts to load early user configuration from default locations.
107    fn try_load_from_default_locations(
108        default_config: &DefaultUserConfig,
109        host_platform: &Platform,
110    ) -> Result<Self, EarlyConfigError> {
111        let paths = user_config_paths().map_err(EarlyConfigError::Discovery)?;
112
113        if paths.is_empty() {
114            debug!("early user config: no config directory found, using defaults");
115            return Ok(Self::resolve_from_defaults(default_config, host_platform));
116        }
117
118        // Try each candidate path.
119        for path in &paths {
120            match EarlyDeserializedConfig::from_path(path) {
121                Ok(Some(user_config)) => {
122                    debug!("early user config: loaded from {path}");
123                    return Ok(Self::resolve(
124                        default_config,
125                        Some(&user_config),
126                        host_platform,
127                    ));
128                }
129                Ok(None) => {
130                    debug!("early user config: file not found at {path}");
131                    continue;
132                }
133                Err(error) => {
134                    // Log a warning, but continue to try other paths or use defaults.
135                    warn!("early user config: error loading {path}: {error}");
136                    continue;
137                }
138            }
139        }
140
141        debug!("early user config: no config file found, using defaults");
142        Ok(Self::resolve_from_defaults(default_config, host_platform))
143    }
144
145    /// Resolves configuration from defaults.
146    fn resolve_from_defaults(default_config: &DefaultUserConfig, host_platform: &Platform) -> Self {
147        Self::resolve(default_config, None, host_platform)
148    }
149
150    /// Resolves configuration from defaults and optional user config.
151    fn resolve(
152        default_config: &DefaultUserConfig,
153        user_config: Option<&EarlyDeserializedConfig>,
154        host_platform: &Platform,
155    ) -> Self {
156        // Compile user overrides.
157        let user_overrides: Vec<CompiledUiOverride> = user_config
158            .map(|c| {
159                c.overrides
160                    .iter()
161                    .filter_map(|o| {
162                        match TargetSpec::new(o.platform.clone()) {
163                            Ok(spec) => Some(CompiledUiOverride::new(spec, o.ui.clone())),
164                            Err(error) => {
165                                // Log a warning, but otherwise skip invalid overrides.
166                                warn!(
167                                    "user config: invalid platform spec '{}': {error}",
168                                    o.platform
169                                );
170                                None
171                            }
172                        }
173                    })
174                    .collect()
175            })
176            .unwrap_or_default();
177
178        // Resolve each setting using standard priority order.
179        let pager = resolve_ui_setting(
180            &default_config.ui.pager,
181            &default_config.ui_overrides,
182            user_config.and_then(|c| c.ui.pager.as_ref()),
183            &user_overrides,
184            host_platform,
185            |data| data.pager(),
186        );
187
188        let paginate = resolve_ui_setting(
189            &default_config.ui.paginate,
190            &default_config.ui_overrides,
191            user_config.and_then(|c| c.ui.paginate.as_ref()),
192            &user_overrides,
193            host_platform,
194            |data| data.paginate(),
195        );
196
197        let streampager = StreampagerConfig {
198            interface: resolve_ui_setting(
199                &default_config.ui.streampager.interface,
200                &default_config.ui_overrides,
201                user_config.and_then(|c| c.ui.streampager_interface()),
202                &user_overrides,
203                host_platform,
204                |data| data.streampager_interface(),
205            ),
206            wrapping: resolve_ui_setting(
207                &default_config.ui.streampager.wrapping,
208                &default_config.ui_overrides,
209                user_config.and_then(|c| c.ui.streampager_wrapping()),
210                &user_overrides,
211                host_platform,
212                |data| data.streampager_wrapping(),
213            ),
214            show_ruler: resolve_ui_setting(
215                &default_config.ui.streampager.show_ruler,
216                &default_config.ui_overrides,
217                user_config.and_then(|c| c.ui.streampager_show_ruler()),
218                &user_overrides,
219                host_platform,
220                |data| data.streampager_show_ruler(),
221            ),
222        };
223
224        Self {
225            pager,
226            paginate,
227            streampager,
228        }
229    }
230}
231
232/// Error type for early config loading.
233///
234/// This is internal and not exposed; errors are logged and defaults used.
235#[derive(Debug)]
236enum EarlyConfigError {
237    Discovery(crate::errors::UserConfigError),
238    /// The file specified via `NEXTEST_USER_CONFIG_FILE` does not exist.
239    FileNotFound(Utf8PathBuf),
240    Read(std::io::Error),
241    Parse(toml::de::Error),
242}
243
244impl fmt::Display for EarlyConfigError {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        match self {
247            Self::Discovery(e) => write!(f, "config discovery: {e}"),
248            Self::FileNotFound(path) => write!(f, "config file not found at {path}"),
249            Self::Read(e) => write!(f, "read: {e}"),
250            Self::Parse(e) => write!(f, "parse: {e}"),
251        }
252    }
253}
254
255/// Deserialized early config - only pager-related fields.
256///
257/// Uses `#[serde(default)]` on all fields to ignore unknown keys and accept
258/// partial configs.
259#[derive(Clone, Debug, Default, Deserialize)]
260#[serde(rename_all = "kebab-case")]
261struct EarlyDeserializedConfig {
262    #[serde(default)]
263    ui: EarlyDeserializedUiConfig,
264    #[serde(default)]
265    overrides: Vec<EarlyDeserializedOverride>,
266}
267
268impl EarlyDeserializedConfig {
269    /// Loads early config from a path.
270    ///
271    /// Returns `Ok(None)` if file doesn't exist, `Err` on read/parse errors.
272    fn from_path(path: &Utf8Path) -> Result<Option<Self>, EarlyConfigError> {
273        let contents = match std::fs::read_to_string(path) {
274            Ok(c) => c,
275            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
276            Err(e) => return Err(EarlyConfigError::Read(e)),
277        };
278
279        let config: Self = toml::from_str(&contents).map_err(EarlyConfigError::Parse)?;
280        Ok(Some(config))
281    }
282}
283
284/// Deserialized UI config - only pager-related fields.
285#[derive(Clone, Debug, Default, Deserialize)]
286#[serde(rename_all = "kebab-case")]
287struct EarlyDeserializedUiConfig {
288    #[serde(default)]
289    pager: Option<PagerSetting>,
290    #[serde(default)]
291    paginate: Option<PaginateSetting>,
292    // Streampager fields flattened for simpler access.
293    #[serde(default, rename = "streampager")]
294    streampager_section: EarlyDeserializedStreampagerConfig,
295}
296
297impl EarlyDeserializedUiConfig {
298    fn streampager_interface(&self) -> Option<&StreampagerInterface> {
299        self.streampager_section.interface.as_ref()
300    }
301
302    fn streampager_wrapping(&self) -> Option<&StreampagerWrapping> {
303        self.streampager_section.wrapping.as_ref()
304    }
305
306    fn streampager_show_ruler(&self) -> Option<&bool> {
307        self.streampager_section.show_ruler.as_ref()
308    }
309}
310
311/// Deserialized streampager config.
312#[derive(Clone, Debug, Default, Deserialize)]
313#[serde(rename_all = "kebab-case")]
314struct EarlyDeserializedStreampagerConfig {
315    #[serde(default)]
316    interface: Option<StreampagerInterface>,
317    #[serde(default)]
318    wrapping: Option<StreampagerWrapping>,
319    #[serde(default)]
320    show_ruler: Option<bool>,
321}
322
323/// Deserialized override entry.
324#[derive(Clone, Debug, Deserialize)]
325#[serde(rename_all = "kebab-case")]
326struct EarlyDeserializedOverride {
327    platform: String,
328    #[serde(default)]
329    ui: DeserializedUiOverrideData,
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::platform::detect_host_platform_for_tests;
336
337    #[test]
338    fn test_early_user_config_defaults() {
339        let host = detect_host_platform_for_tests();
340        let config = EarlyUserConfig::defaults(&host);
341
342        // This should have a configured pager.
343        match &config.pager {
344            PagerSetting::Builtin => {}
345            PagerSetting::External(cmd) => {
346                assert!(!cmd.command_name().is_empty());
347            }
348        }
349
350        // Paginate should default to auto.
351        assert_eq!(config.paginate, PaginateSetting::Auto);
352    }
353
354    #[test]
355    fn test_early_user_config_from_host_platform() {
356        let host = detect_host_platform_for_tests();
357
358        // This should not panic, even if no config file exists.
359        let config = EarlyUserConfig::for_platform(&host, UserConfigLocation::Default);
360
361        // Should return a valid config.
362        match &config.pager {
363            PagerSetting::Builtin => {}
364            PagerSetting::External(cmd) => {
365                assert!(!cmd.command_name().is_empty());
366            }
367        }
368    }
369}