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