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