Skip to main content

nextest_runner/user_config/elements/
ui.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! UI-related user configuration.
5
6use crate::{
7    reporter::{MaxProgressRunning, ShowProgress},
8    user_config::helpers::resolve_ui_setting,
9};
10use serde::{
11    Deserialize, Deserializer,
12    de::{self, Unexpected},
13};
14use std::{collections::BTreeMap, fmt, process::Command};
15use target_spec::{Platform, TargetSpec};
16
17/// Display, progress, and pager settings.
18#[derive(Clone, Debug, Default, Deserialize)]
19#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
20#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
21#[serde(rename_all = "kebab-case")]
22pub(in crate::user_config) struct DeserializedUiConfig {
23    /// Style of progress display shown during test runs.
24    pub(in crate::user_config) show_progress: Option<UiShowProgress>,
25
26    /// Maximum number of running tests to list in the progress bar.
27    ///
28    /// Accepts a non-negative integer, or `"infinite"` for no limit. Excess
29    /// running tests are collapsed into a summary line.
30    #[serde(default, deserialize_with = "deserialize_max_progress_running")]
31    max_progress_running: Option<MaxProgressRunning>,
32
33    /// Enables the interactive keyboard input handler (e.g. `t` to dump test
34    /// status, `Enter` to print a summary line).
35    input_handler: Option<bool>,
36
37    /// Indents captured test output for visual clarity.
38    output_indent: Option<bool>,
39
40    /// Pager command to use for output that benefits from scrolling. Use
41    /// `":builtin"` to select nextest's builtin pager.
42    #[serde(default)]
43    pager: Option<PagerSetting>,
44
45    /// When to send output through the pager.
46    #[serde(default)]
47    paginate: Option<PaginateSetting>,
48
49    /// Settings for the builtin pager (active when `pager = ":builtin"`).
50    #[serde(default)]
51    streampager: DeserializedStreampagerConfig,
52}
53
54/// Default UI configuration with all values required.
55///
56/// This is parsed from the embedded default user config TOML. All fields are
57/// required - if the TOML is missing any field, parsing fails.
58#[derive(Clone, Debug, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub(crate) struct DefaultUiConfig {
61    /// Style of progress display shown during test runs.
62    show_progress: UiShowProgress,
63
64    /// Maximum number of running tests to list in the progress bar.
65    ///
66    /// Accepts a non-negative integer, or `"infinite"` for no limit. Excess
67    /// running tests are collapsed into a summary line.
68    #[serde(deserialize_with = "deserialize_max_progress_running_required")]
69    max_progress_running: MaxProgressRunning,
70
71    /// Enables the interactive keyboard input handler (e.g. `t` to dump test
72    /// status, `Enter` to print a summary line).
73    input_handler: bool,
74
75    /// Indents captured test output for visual clarity.
76    output_indent: bool,
77
78    /// Pager command to use for output that benefits from scrolling. Use
79    /// `":builtin"` to select nextest's builtin pager.
80    pub(in crate::user_config) pager: PagerSetting,
81
82    /// When to send output through the pager.
83    pub(in crate::user_config) paginate: PaginateSetting,
84
85    /// Settings for the builtin pager (active when `pager = ":builtin"`).
86    pub(in crate::user_config) streampager: DefaultStreampagerConfig,
87}
88
89/// Per-platform substitutions for `[ui]` settings.
90///
91/// Each field has the same meaning as in the `[ui]` table. Only the fields
92/// actually set here are substituted on matching platforms.
93#[derive(Clone, Debug, Default, Deserialize)]
94#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
95#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
96#[serde(rename_all = "kebab-case")]
97pub(in crate::user_config) struct DeserializedUiOverrideData {
98    /// Style of progress display shown during test runs.
99    pub(in crate::user_config) show_progress: Option<UiShowProgress>,
100
101    /// Maximum number of running tests to list in the progress bar.
102    ///
103    /// Accepts a non-negative integer, or `"infinite"` for no limit. Excess
104    /// running tests are collapsed into a summary line.
105    #[serde(default, deserialize_with = "deserialize_max_progress_running")]
106    pub(in crate::user_config) max_progress_running: Option<MaxProgressRunning>,
107
108    /// Enables the interactive keyboard input handler (e.g. `t` to dump test
109    /// status, `Enter` to print a summary line).
110    pub(in crate::user_config) input_handler: Option<bool>,
111
112    /// Indents captured test output for visual clarity.
113    pub(in crate::user_config) output_indent: Option<bool>,
114
115    /// Pager command to use for output that benefits from scrolling. Use
116    /// `":builtin"` to select nextest's builtin pager.
117    #[serde(default)]
118    pub(in crate::user_config) pager: Option<PagerSetting>,
119
120    /// When to send output through the pager.
121    #[serde(default)]
122    pub(in crate::user_config) paginate: Option<PaginateSetting>,
123
124    /// Settings for the builtin pager (active when `pager = ":builtin"`).
125    #[serde(default)]
126    pub(in crate::user_config) streampager: DeserializedStreampagerConfig,
127}
128
129/// A compiled UI override with parsed platform spec.
130///
131/// This is created after parsing the platform expression from a
132/// `[[overrides]]` entry.
133#[derive(Clone, Debug)]
134pub(in crate::user_config) struct CompiledUiOverride {
135    platform_spec: TargetSpec,
136    data: UiOverrideData,
137}
138
139impl CompiledUiOverride {
140    /// Creates a new compiled override from a platform spec and UI data.
141    pub(in crate::user_config) fn new(
142        platform_spec: TargetSpec,
143        data: DeserializedUiOverrideData,
144    ) -> Self {
145        Self {
146            platform_spec,
147            data: UiOverrideData {
148                show_progress: data.show_progress,
149                max_progress_running: data.max_progress_running,
150                input_handler: data.input_handler,
151                output_indent: data.output_indent,
152                pager: data.pager,
153                paginate: data.paginate,
154                streampager_interface: data.streampager.interface,
155                streampager_wrapping: data.streampager.wrapping,
156                streampager_show_ruler: data.streampager.show_ruler,
157            },
158        }
159    }
160
161    /// Checks if this override matches the given platform.
162    ///
163    /// Unknown results (e.g., unrecognized target features) are treated as
164    /// non-matching to be conservative.
165    pub(in crate::user_config) fn matches(&self, build_target: &Platform) -> bool {
166        self.platform_spec
167            .eval(build_target)
168            .unwrap_or(/* unknown results are mapped to false */ false)
169    }
170
171    /// Returns a reference to the override data.
172    pub(in crate::user_config) fn data(&self) -> &UiOverrideData {
173        &self.data
174    }
175}
176
177/// Override data for UI settings.
178#[derive(Clone, Debug, Default)]
179pub(in crate::user_config) struct UiOverrideData {
180    show_progress: Option<UiShowProgress>,
181    max_progress_running: Option<MaxProgressRunning>,
182    input_handler: Option<bool>,
183    output_indent: Option<bool>,
184    pager: Option<PagerSetting>,
185    paginate: Option<PaginateSetting>,
186    streampager_interface: Option<StreampagerInterface>,
187    streampager_wrapping: Option<StreampagerWrapping>,
188    streampager_show_ruler: Option<bool>,
189}
190
191impl UiOverrideData {
192    /// Returns the pager setting, if specified.
193    pub(in crate::user_config) fn pager(&self) -> Option<&PagerSetting> {
194        self.pager.as_ref()
195    }
196
197    /// Returns the paginate setting, if specified.
198    pub(in crate::user_config) fn paginate(&self) -> Option<&PaginateSetting> {
199        self.paginate.as_ref()
200    }
201
202    /// Returns the streampager interface, if specified.
203    pub(in crate::user_config) fn streampager_interface(&self) -> Option<&StreampagerInterface> {
204        self.streampager_interface.as_ref()
205    }
206
207    /// Returns the streampager wrapping, if specified.
208    pub(in crate::user_config) fn streampager_wrapping(&self) -> Option<&StreampagerWrapping> {
209        self.streampager_wrapping.as_ref()
210    }
211
212    /// Returns the streampager show-ruler setting, if specified.
213    pub(in crate::user_config) fn streampager_show_ruler(&self) -> Option<&bool> {
214        self.streampager_show_ruler.as_ref()
215    }
216}
217
218/// Resolved UI configuration after applying overrides.
219///
220/// This represents the final resolved settings after evaluating the base
221/// configuration and any matching platform-specific overrides.
222#[derive(Clone, Debug)]
223pub struct UiConfig {
224    /// How to show progress during test runs.
225    pub show_progress: UiShowProgress,
226    /// Maximum running tests to display in the progress bar.
227    pub max_progress_running: MaxProgressRunning,
228    /// Whether to enable the input handler.
229    pub input_handler: bool,
230    /// Whether to indent captured test output.
231    pub output_indent: bool,
232    /// Pager command for output that benefits from scrolling.
233    pub pager: PagerSetting,
234    /// When to paginate output.
235    pub paginate: PaginateSetting,
236    /// Configuration for the builtin streampager.
237    pub streampager: StreampagerConfig,
238}
239
240impl UiConfig {
241    /// Resolves UI configuration from user configs, defaults, and the build
242    /// target of the nextest binary.
243    ///
244    /// Resolution order (highest to lowest priority):
245    ///
246    /// 1. User overrides (first matching override for each setting)
247    /// 2. Default overrides (first matching override for each setting)
248    /// 3. User base config
249    /// 4. Default base config
250    ///
251    /// This matches the resolution order used by repo config.
252    pub(in crate::user_config) fn resolve(
253        default_config: &DefaultUiConfig,
254        default_overrides: &[CompiledUiOverride],
255        user_config: Option<&DeserializedUiConfig>,
256        user_overrides: &[CompiledUiOverride],
257        build_target: &Platform,
258    ) -> Self {
259        Self {
260            show_progress: resolve_ui_setting(
261                &default_config.show_progress,
262                default_overrides,
263                user_config.and_then(|c| c.show_progress.as_ref()),
264                user_overrides,
265                build_target,
266                |data| data.show_progress.as_ref(),
267            ),
268            max_progress_running: resolve_ui_setting(
269                &default_config.max_progress_running,
270                default_overrides,
271                user_config.and_then(|c| c.max_progress_running.as_ref()),
272                user_overrides,
273                build_target,
274                |data| data.max_progress_running.as_ref(),
275            ),
276            input_handler: resolve_ui_setting(
277                &default_config.input_handler,
278                default_overrides,
279                user_config.and_then(|c| c.input_handler.as_ref()),
280                user_overrides,
281                build_target,
282                |data| data.input_handler.as_ref(),
283            ),
284            output_indent: resolve_ui_setting(
285                &default_config.output_indent,
286                default_overrides,
287                user_config.and_then(|c| c.output_indent.as_ref()),
288                user_overrides,
289                build_target,
290                |data| data.output_indent.as_ref(),
291            ),
292            pager: resolve_ui_setting(
293                &default_config.pager,
294                default_overrides,
295                user_config.and_then(|c| c.pager.as_ref()),
296                user_overrides,
297                build_target,
298                |data| data.pager.as_ref(),
299            ),
300            paginate: resolve_ui_setting(
301                &default_config.paginate,
302                default_overrides,
303                user_config.and_then(|c| c.paginate.as_ref()),
304                user_overrides,
305                build_target,
306                |data| data.paginate.as_ref(),
307            ),
308            streampager: StreampagerConfig {
309                interface: resolve_ui_setting(
310                    &default_config.streampager.interface,
311                    default_overrides,
312                    user_config.and_then(|c| c.streampager.interface.as_ref()),
313                    user_overrides,
314                    build_target,
315                    |data| data.streampager_interface.as_ref(),
316                ),
317                wrapping: resolve_ui_setting(
318                    &default_config.streampager.wrapping,
319                    default_overrides,
320                    user_config.and_then(|c| c.streampager.wrapping.as_ref()),
321                    user_overrides,
322                    build_target,
323                    |data| data.streampager_wrapping.as_ref(),
324                ),
325                show_ruler: resolve_ui_setting(
326                    &default_config.streampager.show_ruler,
327                    default_overrides,
328                    user_config.and_then(|c| c.streampager.show_ruler.as_ref()),
329                    user_overrides,
330                    build_target,
331                    |data| data.streampager_show_ruler.as_ref(),
332                ),
333            },
334        }
335    }
336}
337
338/// Style of progress display shown during test runs.
339#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
340#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
341#[serde(rename_all = "kebab-case")]
342pub enum UiShowProgress {
343    /// Picks a display based on terminal capabilities: a progress bar in
344    /// interactive terminals, a counter otherwise.
345    #[default]
346    Auto,
347    /// Disables progress display entirely.
348    None,
349    /// Shows a progress bar listing the currently running tests.
350    Bar,
351    /// Shows a single-line counter (e.g. `(1/10)`).
352    Counter,
353    /// Like `bar` in interactive terminals, but additionally hides successful
354    /// test output (sets `status-level` to `slow` and `final-status-level` to
355    /// `none`). Falls back to `auto` in non-interactive contexts (e.g. piped
356    /// output, CI).
357    Only,
358}
359
360impl From<UiShowProgress> for ShowProgress {
361    fn from(ui: UiShowProgress) -> Self {
362        match ui {
363            UiShowProgress::Auto => ShowProgress::Auto {
364                suppress_success: false,
365            },
366            UiShowProgress::None => ShowProgress::None,
367            UiShowProgress::Bar => ShowProgress::Running,
368            UiShowProgress::Counter => ShowProgress::Counter,
369            UiShowProgress::Only => ShowProgress::Auto {
370                suppress_success: true,
371            },
372        }
373    }
374}
375
376/// When to send output through the pager.
377#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
378#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
379#[serde(rename_all = "kebab-case")]
380pub enum PaginateSetting {
381    /// Pages output from supported commands when stdout is a terminal.
382    #[default]
383    Auto,
384    /// Disables pagination entirely.
385    Never,
386}
387
388/// The special string that indicates the builtin pager should be used.
389pub const BUILTIN_PAGER_NAME: &str = ":builtin";
390
391/// Settings for nextest's builtin pager (active when `pager = ":builtin"`).
392#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
393#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
394#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
395#[serde(rename_all = "kebab-case")]
396pub(in crate::user_config) struct DeserializedStreampagerConfig {
397    /// How the builtin pager uses the alternate screen and when it exits.
398    pub(in crate::user_config) interface: Option<StreampagerInterface>,
399    /// How the builtin pager wraps long lines.
400    pub(in crate::user_config) wrapping: Option<StreampagerWrapping>,
401    /// Whether to show a ruler at the bottom of the builtin pager.
402    pub(in crate::user_config) show_ruler: Option<bool>,
403}
404
405/// Default streampager configuration (all fields required).
406///
407/// Used in the embedded default config.
408#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
409#[serde(rename_all = "kebab-case")]
410pub(in crate::user_config) struct DefaultStreampagerConfig {
411    /// How the builtin pager uses the alternate screen and when it exits.
412    pub(in crate::user_config) interface: StreampagerInterface,
413    /// How the builtin pager wraps long lines.
414    pub(in crate::user_config) wrapping: StreampagerWrapping,
415    /// Whether to show a ruler at the bottom of the builtin pager.
416    pub(in crate::user_config) show_ruler: bool,
417}
418
419/// Resolved streampager configuration.
420///
421/// These settings control behavior when `pager = ":builtin"` is configured.
422#[derive(Clone, Copy, Debug, PartialEq, Eq)]
423pub struct StreampagerConfig {
424    /// How the builtin pager uses the alternate screen and when it exits.
425    pub interface: StreampagerInterface,
426    /// How the builtin pager wraps long lines.
427    pub wrapping: StreampagerWrapping,
428    /// Whether to show a ruler at the bottom of the builtin pager.
429    pub show_ruler: bool,
430}
431
432impl StreampagerConfig {
433    /// Converts to the streampager library's interface mode.
434    pub fn streampager_interface_mode(&self) -> streampager::config::InterfaceMode {
435        use streampager::config::InterfaceMode;
436        match self.interface {
437            StreampagerInterface::FullScreenClearOutput => InterfaceMode::FullScreen,
438            StreampagerInterface::QuitIfOnePage => InterfaceMode::Hybrid,
439            StreampagerInterface::QuitQuicklyOrClearOutput => {
440                InterfaceMode::Delayed(std::time::Duration::from_secs(2))
441            }
442        }
443    }
444
445    /// Converts to the streampager library's wrapping mode.
446    pub fn streampager_wrapping_mode(&self) -> streampager::config::WrappingMode {
447        use streampager::config::WrappingMode;
448        match self.wrapping {
449            StreampagerWrapping::None => WrappingMode::Unwrapped,
450            StreampagerWrapping::Word => WrappingMode::WordBoundary,
451            StreampagerWrapping::Anywhere => WrappingMode::GraphemeBoundary,
452        }
453    }
454}
455
456/// How the builtin pager uses the alternate screen and when it exits.
457#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
458#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
459#[serde(rename_all = "kebab-case")]
460pub enum StreampagerInterface {
461    /// Exits immediately if the output fits on one page; otherwise switches
462    /// to full-screen and clears on exit.
463    #[default]
464    QuitIfOnePage,
465    /// Always uses full-screen mode and clears the screen on exit.
466    FullScreenClearOutput,
467    /// Waits briefly before entering full-screen mode; clears on exit only if
468    /// it switched to full-screen.
469    QuitQuicklyOrClearOutput,
470}
471
472/// How long lines are wrapped in the builtin pager.
473#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
474#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
475#[serde(rename_all = "kebab-case")]
476pub enum StreampagerWrapping {
477    /// Disables wrapping; long lines extend off-screen and can be scrolled
478    /// horizontally.
479    None,
480    /// Wraps at word boundaries.
481    #[default]
482    Word,
483    /// Wraps at any character (grapheme) boundary.
484    Anywhere,
485}
486
487/// A command with optional arguments and environment variables.
488///
489/// Supports three input formats, all normalized to the same representation:
490///
491/// - String: `"less -FRX"` (split on whitespace)
492/// - Array: `["less", "-FRX"]`
493/// - Structured: `{ command = ["less", "-FRX"], env = { LESSCHARSET = "utf-8" } }`
494#[derive(Clone, Debug, PartialEq, Eq)]
495pub struct CommandNameAndArgs {
496    /// The command and its arguments (non-empty after deserialization).
497    command: Vec<String>,
498    /// Environment variables to set when running the command.
499    env: BTreeMap<String, String>,
500}
501
502impl CommandNameAndArgs {
503    /// Returns the command name.
504    pub fn command_name(&self) -> &str {
505        // The command is validated to be non-empty during deserialization.
506        &self.command[0]
507    }
508
509    /// Returns the arguments.
510    pub fn args(&self) -> &[String] {
511        &self.command[1..]
512    }
513
514    /// Creates a [`std::process::Command`] from this configuration.
515    pub fn to_command(&self) -> Command {
516        let mut cmd = Command::new(self.command_name());
517        cmd.args(self.args());
518        cmd.envs(&self.env);
519        cmd
520    }
521}
522
523impl<'de> Deserialize<'de> for CommandNameAndArgs {
524    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
525        deserializer.deserialize_any(CommandNameAndArgsVisitor)
526    }
527}
528
529#[cfg(feature = "config-schema")]
530impl schemars::JsonSchema for CommandNameAndArgs {
531    fn schema_name() -> std::borrow::Cow<'static, str> {
532        "CommandNameAndArgs".into()
533    }
534
535    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
536        // The string form is split via `shell_words`, which rejects empty and
537        // whitespace-only inputs. `pattern: "\\S"` (one non-whitespace
538        // character anywhere) mirrors that — `minLength` alone would still
539        // accept whitespace-only strings.
540        schemars::json_schema!({
541            "title": "CommandNameAndArgs",
542            "oneOf": [
543                {
544                    "type": "string",
545                    "minLength": 1,
546                    "pattern": "\\S",
547                },
548                {
549                    "type": "array",
550                    "items": generator.subschema_for::<String>(),
551                    "minItems": 1,
552                },
553                {
554                    "type": "object",
555                    "properties": {
556                        "command": {
557                            "type": "array",
558                            "items": generator.subschema_for::<String>(),
559                            "minItems": 1,
560                        },
561                        "env": generator.subschema_for::<BTreeMap<String, String>>(),
562                    },
563                    "required": ["command"],
564                    "additionalProperties": false,
565                }
566            ]
567        })
568    }
569}
570
571/// Visitor for deserializing CommandNameAndArgs.
572struct CommandNameAndArgsVisitor;
573
574impl<'de> de::Visitor<'de> for CommandNameAndArgsVisitor {
575    type Value = CommandNameAndArgs;
576
577    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
578        formatter.write_str(
579            "a command string (\"less -FRX\"), \
580             an array ([\"less\", \"-FRX\"]), \
581             or a table ({ command = [\"less\", \"-FRX\"], env = { ... } })",
582        )
583    }
584
585    fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
586        let command: Vec<String> = shell_words::split(v).map_err(de::Error::custom)?;
587        if command.is_empty() {
588            return Err(de::Error::custom("command string must not be empty"));
589        }
590        Ok(CommandNameAndArgs {
591            command,
592            env: BTreeMap::new(),
593        })
594    }
595
596    fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
597        let mut command = Vec::new();
598        while let Some(arg) = seq.next_element::<String>()? {
599            command.push(arg);
600        }
601        if command.is_empty() {
602            return Err(de::Error::custom("command array must not be empty"));
603        }
604        Ok(CommandNameAndArgs {
605            command,
606            env: BTreeMap::new(),
607        })
608    }
609
610    fn visit_map<A: de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
611        #[derive(Deserialize)]
612        struct StructuredInner {
613            command: Vec<String>,
614            #[serde(default)]
615            env: BTreeMap<String, String>,
616        }
617
618        let inner = StructuredInner::deserialize(de::value::MapAccessDeserializer::new(map))?;
619        if inner.command.is_empty() {
620            return Err(de::Error::custom("command array must not be empty"));
621        }
622        Ok(CommandNameAndArgs {
623            command: inner.command,
624            env: inner.env,
625        })
626    }
627}
628
629/// Controls which pager to use for output that benefits from scrolling.
630///
631/// This specifies *which* pager to use; whether to actually paginate is
632/// controlled by [`PaginateSetting`].
633#[derive(Clone, Debug, PartialEq, Eq)]
634pub enum PagerSetting {
635    /// Use the builtin streampager.
636    Builtin,
637    /// Use an external command.
638    External(CommandNameAndArgs),
639}
640
641// Only used in unit tests -- in regular code, the default is looked up via
642// default-user-config.toml.
643#[cfg(test)]
644impl Default for PagerSetting {
645    fn default() -> Self {
646        Self::External(CommandNameAndArgs {
647            command: vec!["less".to_owned(), "-FRX".to_owned()],
648            env: [("LESSCHARSET".to_owned(), "utf-8".to_owned())]
649                .into_iter()
650                .collect(),
651        })
652    }
653}
654
655impl<'de> Deserialize<'de> for PagerSetting {
656    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
657        deserializer.deserialize_any(PagerSettingVisitor)
658    }
659}
660
661#[cfg(feature = "config-schema")]
662impl schemars::JsonSchema for PagerSetting {
663    fn schema_name() -> std::borrow::Cow<'static, str> {
664        "PagerSetting".into()
665    }
666
667    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
668        // `PagerSetting` accepts everything that `CommandNameAndArgs` does, plus
669        // the special string ":builtin". Defer to `CommandNameAndArgs`'s schema
670        // for the command forms so the two stay in sync. `anyOf` (rather than
671        // `oneOf`) is used because the string ":builtin" satisfies both
672        // branches — at runtime it is special-cased to mean the builtin pager.
673        schemars::json_schema!({
674            "title": "PagerSetting",
675            "anyOf": [
676                { "type": "string", "const": BUILTIN_PAGER_NAME },
677                generator.subschema_for::<CommandNameAndArgs>(),
678            ]
679        })
680    }
681}
682
683/// Visitor for deserializing PagerSetting.
684struct PagerSettingVisitor;
685
686impl<'de> de::Visitor<'de> for PagerSettingVisitor {
687    type Value = PagerSetting;
688
689    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
690        formatter
691            .write_str("\":builtin\", a command string, an array, or a table with command and env")
692    }
693
694    fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
695        // Check for the special ":builtin" value.
696        if v == BUILTIN_PAGER_NAME {
697            return Ok(PagerSetting::Builtin);
698        }
699        let cmd = CommandNameAndArgsVisitor.visit_str(v)?;
700        Ok(PagerSetting::External(cmd))
701    }
702
703    fn visit_seq<A: de::SeqAccess<'de>>(self, seq: A) -> Result<Self::Value, A::Error> {
704        let args = CommandNameAndArgsVisitor.visit_seq(seq)?;
705        Ok(PagerSetting::External(args))
706    }
707
708    fn visit_map<A: de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
709        let args = CommandNameAndArgsVisitor.visit_map(map)?;
710        Ok(PagerSetting::External(args))
711    }
712}
713
714/// Visitor for deserializing max-progress-running (integer or `"infinite"`).
715struct MaxProgressRunningVisitor;
716
717impl<'de> de::Visitor<'de> for MaxProgressRunningVisitor {
718    type Value = MaxProgressRunning;
719
720    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
721        formatter.write_str("a non-negative integer or \"infinite\"")
722    }
723
724    fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
725        Ok(MaxProgressRunning::Count(v as usize))
726    }
727
728    fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
729        if v < 0 {
730            Err(E::invalid_value(Unexpected::Signed(v), &self))
731        } else {
732            Ok(MaxProgressRunning::Count(v as usize))
733        }
734    }
735
736    fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
737        if v == "infinite" {
738            Ok(MaxProgressRunning::Infinite)
739        } else {
740            Err(E::invalid_value(Unexpected::Str(v), &self))
741        }
742    }
743}
744
745fn deserialize_max_progress_running<'de, D>(
746    deserializer: D,
747) -> Result<Option<MaxProgressRunning>, D::Error>
748where
749    D: Deserializer<'de>,
750{
751    deserializer.deserialize_option(OptionMaxProgressRunningVisitor)
752}
753
754/// Visitor for deserializing Option<MaxProgressRunning>.
755struct OptionMaxProgressRunningVisitor;
756
757impl<'de> de::Visitor<'de> for OptionMaxProgressRunningVisitor {
758    type Value = Option<MaxProgressRunning>;
759
760    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
761        formatter.write_str("a non-negative integer, \"infinite\", or null")
762    }
763
764    fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
765        Ok(None)
766    }
767
768    fn visit_some<D: Deserializer<'de>>(self, deserializer: D) -> Result<Self::Value, D::Error> {
769        deserializer
770            .deserialize_any(MaxProgressRunningVisitor)
771            .map(Some)
772    }
773
774    fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
775        Ok(None)
776    }
777}
778
779fn deserialize_max_progress_running_required<'de, D>(
780    deserializer: D,
781) -> Result<MaxProgressRunning, D::Error>
782where
783    D: Deserializer<'de>,
784{
785    deserializer.deserialize_any(MaxProgressRunningVisitor)
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791    use crate::user_config::DefaultUserConfig;
792
793    /// Helper to create a CompiledUiOverride for tests.
794    fn make_override(platform: &str, data: DeserializedUiOverrideData) -> CompiledUiOverride {
795        let platform_spec =
796            TargetSpec::new(platform.to_string()).expect("valid platform spec in test");
797        CompiledUiOverride::new(platform_spec, data)
798    }
799
800    #[test]
801    fn test_ui_config_show_progress() {
802        // Test valid values.
803        let config: DeserializedUiConfig = toml::from_str(r#"show-progress = "auto""#).unwrap();
804        assert!(matches!(config.show_progress, Some(UiShowProgress::Auto)));
805
806        let config: DeserializedUiConfig = toml::from_str(r#"show-progress = "none""#).unwrap();
807        assert!(matches!(config.show_progress, Some(UiShowProgress::None)));
808
809        let config: DeserializedUiConfig = toml::from_str(r#"show-progress = "bar""#).unwrap();
810        assert!(matches!(config.show_progress, Some(UiShowProgress::Bar)));
811
812        let config: DeserializedUiConfig = toml::from_str(r#"show-progress = "counter""#).unwrap();
813        assert!(matches!(
814            config.show_progress,
815            Some(UiShowProgress::Counter)
816        ));
817
818        let config: DeserializedUiConfig = toml::from_str(r#"show-progress = "only""#).unwrap();
819        assert!(matches!(config.show_progress, Some(UiShowProgress::Only)));
820
821        // Test missing value.
822        let config: DeserializedUiConfig = toml::from_str("").unwrap();
823        assert!(config.show_progress.is_none());
824
825        // Test invalid value.
826        toml::from_str::<DeserializedUiConfig>(r#"show-progress = "invalid""#).unwrap_err();
827    }
828
829    #[test]
830    fn test_ui_show_progress_to_show_progress() {
831        // Test conversion to ShowProgress.
832        assert_eq!(
833            ShowProgress::from(UiShowProgress::Auto),
834            ShowProgress::Auto {
835                suppress_success: false
836            }
837        );
838        assert_eq!(ShowProgress::from(UiShowProgress::None), ShowProgress::None);
839        assert_eq!(
840            ShowProgress::from(UiShowProgress::Bar),
841            ShowProgress::Running
842        );
843        assert_eq!(
844            ShowProgress::from(UiShowProgress::Counter),
845            ShowProgress::Counter
846        );
847        // Only maps to Auto with suppress_success: the displayer handles hiding
848        // successful output when interactive.
849        assert_eq!(
850            ShowProgress::from(UiShowProgress::Only),
851            ShowProgress::Auto {
852                suppress_success: true
853            }
854        );
855    }
856
857    #[test]
858    fn test_ui_config_max_progress_running() {
859        // Test integer values.
860        let config: DeserializedUiConfig = toml::from_str("max-progress-running = 10").unwrap();
861        assert!(matches!(
862            config.max_progress_running,
863            Some(MaxProgressRunning::Count(10))
864        ));
865
866        let config: DeserializedUiConfig = toml::from_str("max-progress-running = 0").unwrap();
867        assert!(matches!(
868            config.max_progress_running,
869            Some(MaxProgressRunning::Count(0))
870        ));
871
872        // Test string "infinite".
873        let config: DeserializedUiConfig =
874            toml::from_str(r#"max-progress-running = "infinite""#).unwrap();
875        assert!(matches!(
876            config.max_progress_running,
877            Some(MaxProgressRunning::Infinite)
878        ));
879
880        // Test that matching is case-sensitive.
881        toml::from_str::<DeserializedUiConfig>(r#"max-progress-running = "INFINITE""#).unwrap_err();
882
883        // Numeric strings are rejected.
884        toml::from_str::<DeserializedUiConfig>(r#"max-progress-running = "8""#).unwrap_err();
885
886        // Test missing value.
887        let config: DeserializedUiConfig = toml::from_str("").unwrap();
888        assert!(config.max_progress_running.is_none());
889
890        // Test invalid value.
891        toml::from_str::<DeserializedUiConfig>(r#"max-progress-running = "invalid""#).unwrap_err();
892    }
893
894    #[test]
895    fn test_ui_config_input_handler() {
896        let config: DeserializedUiConfig = toml::from_str("input-handler = true").unwrap();
897        assert_eq!(config.input_handler, Some(true));
898        let config: DeserializedUiConfig = toml::from_str("input-handler = false").unwrap();
899        assert_eq!(config.input_handler, Some(false));
900        let config: DeserializedUiConfig = toml::from_str("").unwrap();
901        assert!(config.input_handler.is_none());
902    }
903
904    #[test]
905    fn test_ui_config_output_indent() {
906        let config: DeserializedUiConfig = toml::from_str("output-indent = true").unwrap();
907        assert_eq!(config.output_indent, Some(true));
908        let config: DeserializedUiConfig = toml::from_str("output-indent = false").unwrap();
909        assert_eq!(config.output_indent, Some(false));
910        let config: DeserializedUiConfig = toml::from_str("").unwrap();
911        assert!(config.output_indent.is_none());
912    }
913
914    #[test]
915    fn test_resolved_ui_config_defaults_only() {
916        let defaults = DefaultUserConfig::from_embedded().ui;
917
918        let build_target =
919            Platform::build_target().expect("nextest is built for a supported platform");
920        let resolved = UiConfig::resolve(&defaults, &[], None, &[], &build_target);
921
922        // Resolved values should match the embedded defaults.
923        assert_eq!(resolved.show_progress, defaults.show_progress);
924        assert_eq!(resolved.max_progress_running, defaults.max_progress_running);
925        assert_eq!(resolved.input_handler, defaults.input_handler);
926        assert_eq!(resolved.output_indent, defaults.output_indent);
927    }
928
929    #[test]
930    fn test_resolved_ui_config_user_config_overrides_defaults() {
931        let defaults = DefaultUserConfig::from_embedded().ui;
932
933        let user_config = DeserializedUiConfig {
934            show_progress: Some(UiShowProgress::Bar),
935            max_progress_running: Some(MaxProgressRunning::Count(4)),
936            output_indent: Some(false),
937            ..Default::default()
938        };
939
940        let build_target =
941            Platform::build_target().expect("nextest is built for a supported platform");
942        let resolved = UiConfig::resolve(&defaults, &[], Some(&user_config), &[], &build_target);
943
944        assert_eq!(resolved.show_progress, UiShowProgress::Bar);
945        assert_eq!(resolved.max_progress_running, MaxProgressRunning::Count(4));
946        assert_eq!(resolved.input_handler, defaults.input_handler); // From defaults.
947        assert!(!resolved.output_indent);
948    }
949
950    #[test]
951    fn test_resolved_ui_config_user_override_applies() {
952        let defaults = DefaultUserConfig::from_embedded().ui;
953
954        // Create a user override that matches any platform.
955        let override_ = make_override(
956            "cfg(all())",
957            DeserializedUiOverrideData {
958                show_progress: Some(UiShowProgress::Counter),
959                input_handler: Some(false),
960                ..Default::default()
961            },
962        );
963
964        let build_target =
965            Platform::build_target().expect("nextest is built for a supported platform");
966        let resolved = UiConfig::resolve(&defaults, &[], None, &[override_], &build_target);
967
968        assert_eq!(resolved.show_progress, UiShowProgress::Counter);
969        assert_eq!(resolved.max_progress_running, defaults.max_progress_running); // From defaults.
970        assert!(!resolved.input_handler);
971        assert_eq!(resolved.output_indent, defaults.output_indent); // From defaults.
972    }
973
974    #[test]
975    fn test_resolved_ui_config_default_override_applies() {
976        let defaults = DefaultUserConfig::from_embedded().ui;
977
978        // Create a default override that matches any platform.
979        let override_ = make_override(
980            "cfg(all())",
981            DeserializedUiOverrideData {
982                show_progress: Some(UiShowProgress::Counter),
983                input_handler: Some(false),
984                ..Default::default()
985            },
986        );
987
988        let build_target =
989            Platform::build_target().expect("nextest is built for a supported platform");
990        let resolved = UiConfig::resolve(&defaults, &[override_], None, &[], &build_target);
991
992        assert_eq!(resolved.show_progress, UiShowProgress::Counter);
993        assert_eq!(resolved.max_progress_running, defaults.max_progress_running); // From defaults.
994        assert!(!resolved.input_handler);
995        assert_eq!(resolved.output_indent, defaults.output_indent); // From defaults.
996    }
997
998    #[test]
999    fn test_resolved_ui_config_platform_override_no_match() {
1000        let defaults = DefaultUserConfig::from_embedded().ui;
1001
1002        // Create an override that never matches (cfg(any()) with no arguments
1003        // is false).
1004        let override_ = make_override(
1005            "cfg(any())",
1006            DeserializedUiOverrideData {
1007                show_progress: Some(UiShowProgress::Counter),
1008                max_progress_running: Some(MaxProgressRunning::Count(2)),
1009                input_handler: Some(false),
1010                output_indent: Some(false),
1011                pager: Some(PagerSetting::default()),
1012                paginate: Some(PaginateSetting::Never),
1013                streampager: Default::default(),
1014            },
1015        );
1016
1017        let build_target =
1018            Platform::build_target().expect("nextest is built for a supported platform");
1019        let resolved = UiConfig::resolve(&defaults, &[], None, &[override_], &build_target);
1020
1021        // Nothing should be overridden - all values should match defaults.
1022        assert_eq!(resolved.show_progress, defaults.show_progress);
1023        assert_eq!(resolved.max_progress_running, defaults.max_progress_running);
1024        assert_eq!(resolved.input_handler, defaults.input_handler);
1025        assert_eq!(resolved.output_indent, defaults.output_indent);
1026    }
1027
1028    #[test]
1029    fn test_resolved_ui_config_first_matching_user_override_wins() {
1030        let defaults = DefaultUserConfig::from_embedded().ui;
1031
1032        // Create two user overrides that both match (cfg(all()) is always true).
1033        let override1 = make_override(
1034            "cfg(all())",
1035            DeserializedUiOverrideData {
1036                show_progress: Some(UiShowProgress::Bar),
1037                ..Default::default()
1038            },
1039        );
1040
1041        let override2 = make_override(
1042            "cfg(all())",
1043            DeserializedUiOverrideData {
1044                show_progress: Some(UiShowProgress::Counter), // Should be ignored.
1045                max_progress_running: Some(MaxProgressRunning::Count(4)),
1046                ..Default::default()
1047            },
1048        );
1049
1050        let build_target =
1051            Platform::build_target().expect("nextest is built for a supported platform");
1052        let resolved =
1053            UiConfig::resolve(&defaults, &[], None, &[override1, override2], &build_target);
1054
1055        // First override wins for show_progress.
1056        assert_eq!(resolved.show_progress, UiShowProgress::Bar);
1057        // Second override's max_progress_running applies (first didn't set it).
1058        assert_eq!(resolved.max_progress_running, MaxProgressRunning::Count(4));
1059    }
1060
1061    #[test]
1062    fn test_resolved_ui_config_user_override_beats_default_override() {
1063        let defaults = DefaultUserConfig::from_embedded().ui;
1064
1065        // User override sets show_progress.
1066        let user_override = make_override(
1067            "cfg(all())",
1068            DeserializedUiOverrideData {
1069                show_progress: Some(UiShowProgress::Bar),
1070                ..Default::default()
1071            },
1072        );
1073
1074        // Default override sets show_progress and max_progress_running.
1075        let default_override = make_override(
1076            "cfg(all())",
1077            DeserializedUiOverrideData {
1078                show_progress: Some(UiShowProgress::Counter), // Should be ignored.
1079                max_progress_running: Some(MaxProgressRunning::Count(4)),
1080                ..Default::default()
1081            },
1082        );
1083
1084        let build_target =
1085            Platform::build_target().expect("nextest is built for a supported platform");
1086        let resolved = UiConfig::resolve(
1087            &defaults,
1088            &[default_override],
1089            None,
1090            &[user_override],
1091            &build_target,
1092        );
1093
1094        // User override wins for show_progress.
1095        assert_eq!(resolved.show_progress, UiShowProgress::Bar);
1096        // Default override applies for max_progress_running (user didn't set it).
1097        assert_eq!(resolved.max_progress_running, MaxProgressRunning::Count(4));
1098    }
1099
1100    #[test]
1101    fn test_resolved_ui_config_override_beats_user_base() {
1102        let defaults = DefaultUserConfig::from_embedded().ui;
1103
1104        // User base config sets show_progress.
1105        let user_config = DeserializedUiConfig {
1106            show_progress: Some(UiShowProgress::None),
1107            max_progress_running: Some(MaxProgressRunning::Count(2)),
1108            ..Default::default()
1109        };
1110
1111        // Default override sets show_progress (should beat user base).
1112        let default_override = make_override(
1113            "cfg(all())",
1114            DeserializedUiOverrideData {
1115                show_progress: Some(UiShowProgress::Counter),
1116                ..Default::default()
1117            },
1118        );
1119
1120        let build_target =
1121            Platform::build_target().expect("nextest is built for a supported platform");
1122        let resolved = UiConfig::resolve(
1123            &defaults,
1124            &[default_override],
1125            Some(&user_config),
1126            &[],
1127            &build_target,
1128        );
1129
1130        // Default override is chosen over user base for show_progress.
1131        assert_eq!(resolved.show_progress, UiShowProgress::Counter);
1132        // User base applies for max_progress_running (override didn't set it).
1133        assert_eq!(resolved.max_progress_running, MaxProgressRunning::Count(2));
1134    }
1135
1136    #[test]
1137    fn test_paginate_setting_parsing() {
1138        // Test "auto".
1139        let config: DeserializedUiConfig = toml::from_str(r#"paginate = "auto""#).unwrap();
1140        assert_eq!(config.paginate, Some(PaginateSetting::Auto));
1141
1142        // Test "never".
1143        let config: DeserializedUiConfig = toml::from_str(r#"paginate = "never""#).unwrap();
1144        assert_eq!(config.paginate, Some(PaginateSetting::Never));
1145
1146        // Test missing value.
1147        let config: DeserializedUiConfig = toml::from_str("").unwrap();
1148        assert!(config.paginate.is_none());
1149
1150        // Test invalid value.
1151        let err = toml::from_str::<DeserializedUiConfig>(r#"paginate = "invalid""#).unwrap_err();
1152        assert!(
1153            err.to_string().contains("unknown variant"),
1154            "error should mention 'unknown variant': {err}"
1155        );
1156    }
1157
1158    #[test]
1159    fn test_command_name_and_args_parsing() {
1160        #[derive(Debug, Deserialize)]
1161        struct Wrapper {
1162            cmd: CommandNameAndArgs,
1163        }
1164
1165        // String format: split using shell word parsing.
1166        let wrapper: Wrapper = toml::from_str(r#"cmd = "less -FRX""#).unwrap();
1167        assert_eq!(
1168            wrapper.cmd,
1169            CommandNameAndArgs {
1170                command: vec!["less".to_owned(), "-FRX".to_owned()],
1171                env: BTreeMap::new(),
1172            }
1173        );
1174        assert_eq!(wrapper.cmd.command_name(), "less");
1175        assert_eq!(wrapper.cmd.args(), &["-FRX".to_owned()]);
1176
1177        // Array format: each element is a separate argument.
1178        let wrapper: Wrapper = toml::from_str(r#"cmd = ["less", "-F", "-R", "-X"]"#).unwrap();
1179        assert_eq!(
1180            wrapper.cmd,
1181            CommandNameAndArgs {
1182                command: vec![
1183                    "less".to_owned(),
1184                    "-F".to_owned(),
1185                    "-R".to_owned(),
1186                    "-X".to_owned()
1187                ],
1188                env: BTreeMap::new(),
1189            }
1190        );
1191        assert_eq!(wrapper.cmd.command_name(), "less");
1192        assert_eq!(
1193            wrapper.cmd.args(),
1194            &["-F".to_owned(), "-R".to_owned(), "-X".to_owned()]
1195        );
1196
1197        // Structured format: command array with optional env.
1198        let cmd: CommandNameAndArgs = toml::from_str(
1199            r#"
1200            command = ["less", "-FRX"]
1201            env = { LESSCHARSET = "utf-8" }
1202            "#,
1203        )
1204        .unwrap();
1205        let expected_env: BTreeMap<String, String> =
1206            [("LESSCHARSET".to_owned(), "utf-8".to_owned())]
1207                .into_iter()
1208                .collect();
1209        assert_eq!(
1210            cmd,
1211            CommandNameAndArgs {
1212                command: vec!["less".to_owned(), "-FRX".to_owned()],
1213                env: expected_env,
1214            }
1215        );
1216        assert_eq!(cmd.command_name(), "less");
1217        assert_eq!(cmd.args(), &["-FRX".to_owned()]);
1218
1219        // Shell quoting: double quotes preserve spaces.
1220        let wrapper: Wrapper = toml::from_str(r#"cmd = 'my-pager "arg with spaces"'"#).unwrap();
1221        assert_eq!(
1222            wrapper.cmd,
1223            CommandNameAndArgs {
1224                command: vec!["my-pager".to_owned(), "arg with spaces".to_owned()],
1225                env: BTreeMap::new(),
1226            }
1227        );
1228
1229        // Shell quoting: single quotes preserve spaces.
1230        let wrapper: Wrapper = toml::from_str(r#"cmd = "my-pager 'arg with spaces'""#).unwrap();
1231        assert_eq!(
1232            wrapper.cmd,
1233            CommandNameAndArgs {
1234                command: vec!["my-pager".to_owned(), "arg with spaces".to_owned()],
1235                env: BTreeMap::new(),
1236            }
1237        );
1238
1239        // Shell quoting: escaped quotes within double quotes.
1240        let wrapper: Wrapper =
1241            toml::from_str(r#"cmd = 'my-pager "quoted \"nested\" arg"'"#).unwrap();
1242        assert_eq!(
1243            wrapper.cmd,
1244            CommandNameAndArgs {
1245                command: vec!["my-pager".to_owned(), "quoted \"nested\" arg".to_owned()],
1246                env: BTreeMap::new(),
1247            }
1248        );
1249
1250        // Shell quoting: path with spaces.
1251        let wrapper: Wrapper = toml::from_str(r#"cmd = '"/path/to/my pager" --flag'"#).unwrap();
1252        assert_eq!(
1253            wrapper.cmd,
1254            CommandNameAndArgs {
1255                command: vec!["/path/to/my pager".to_owned(), "--flag".to_owned()],
1256                env: BTreeMap::new(),
1257            }
1258        );
1259
1260        // Shell quoting: multiple quoted arguments.
1261        let wrapper: Wrapper =
1262            toml::from_str(r#"cmd = 'cmd "first arg" "second arg" third'"#).unwrap();
1263        assert_eq!(
1264            wrapper.cmd,
1265            CommandNameAndArgs {
1266                command: vec![
1267                    "cmd".to_owned(),
1268                    "first arg".to_owned(),
1269                    "second arg".to_owned(),
1270                    "third".to_owned(),
1271                ],
1272                env: BTreeMap::new(),
1273            }
1274        );
1275    }
1276
1277    #[test]
1278    fn test_command_and_pager_empty_errors() {
1279        #[derive(Debug, Deserialize)]
1280        struct Wrapper {
1281            #[expect(dead_code)]
1282            cmd: CommandNameAndArgs,
1283        }
1284
1285        // Test CommandNameAndArgs empty cases.
1286        let cmd_cases = [
1287            ("empty array", "cmd = []"),
1288            ("empty string", r#"cmd = """#),
1289            ("whitespace-only string", r#"cmd = "   ""#),
1290            (
1291                "structured with empty command",
1292                r#"cmd = { command = [], env = { LESSCHARSET = "utf-8" } }"#,
1293            ),
1294        ];
1295
1296        for (name, input) in cmd_cases {
1297            let err = toml::from_str::<Wrapper>(input).unwrap_err();
1298            assert!(
1299                err.to_string().contains("must not be empty"),
1300                "CommandNameAndArgs {name}: error should mention 'must not be empty': {err}"
1301            );
1302        }
1303
1304        // Test PagerSetting empty cases (via DeserializedUiConfig).
1305        let pager_cases = [
1306            ("empty array", "pager = []"),
1307            ("empty string", r#"pager = """#),
1308        ];
1309
1310        for (name, input) in pager_cases {
1311            let err = toml::from_str::<DeserializedUiConfig>(input).unwrap_err();
1312            assert!(
1313                err.to_string().contains("must not be empty"),
1314                "PagerSetting {name}: error should mention 'must not be empty': {err}"
1315            );
1316        }
1317
1318        // Test invalid shell quoting (unclosed quotes).
1319        let unclosed_quote_cases = [
1320            ("unclosed double quote", r#"cmd = 'pager "unclosed'"#),
1321            ("unclosed single quote", r#"cmd = "pager 'unclosed""#),
1322        ];
1323
1324        for (name, input) in unclosed_quote_cases {
1325            let err = toml::from_str::<Wrapper>(input).unwrap_err();
1326            assert!(
1327                err.to_string().contains("missing closing quote"),
1328                "CommandNameAndArgs {name}: error should mention 'missing closing quote': {err}"
1329            );
1330        }
1331    }
1332
1333    #[test]
1334    fn test_command_name_and_args_to_command() {
1335        // Test that to_command produces a valid Command.
1336        let cmd = CommandNameAndArgs {
1337            command: vec!["echo".to_owned(), "hello".to_owned()],
1338            env: BTreeMap::new(),
1339        };
1340        let std_cmd = cmd.to_command();
1341        assert_eq!(cmd.command_name(), "echo");
1342        drop(std_cmd);
1343    }
1344
1345    #[test]
1346    fn test_pager_setting_parsing() {
1347        // String format.
1348        let config: DeserializedUiConfig = toml::from_str(r#"pager = "less -FRX""#).unwrap();
1349        assert_eq!(
1350            config.pager,
1351            Some(PagerSetting::External(CommandNameAndArgs {
1352                command: vec!["less".to_owned(), "-FRX".to_owned()],
1353                env: BTreeMap::new(),
1354            }))
1355        );
1356
1357        // Array format.
1358        let config: DeserializedUiConfig = toml::from_str(r#"pager = ["less", "-FRX"]"#).unwrap();
1359        assert_eq!(
1360            config.pager,
1361            Some(PagerSetting::External(CommandNameAndArgs {
1362                command: vec!["less".to_owned(), "-FRX".to_owned()],
1363                env: BTreeMap::new(),
1364            }))
1365        );
1366
1367        // Structured format with env.
1368        let config: DeserializedUiConfig = toml::from_str(
1369            r#"
1370            [pager]
1371            command = ["less", "-FRX"]
1372            env = { LESSCHARSET = "utf-8" }
1373            "#,
1374        )
1375        .unwrap();
1376        let expected_env: BTreeMap<String, String> =
1377            [("LESSCHARSET".to_owned(), "utf-8".to_owned())]
1378                .into_iter()
1379                .collect();
1380        assert_eq!(
1381            config.pager,
1382            Some(PagerSetting::External(CommandNameAndArgs {
1383                command: vec!["less".to_owned(), "-FRX".to_owned()],
1384                env: expected_env,
1385            }))
1386        );
1387
1388        // Missing pager (None).
1389        let config: DeserializedUiConfig = toml::from_str("").unwrap();
1390        assert!(config.pager.is_none());
1391    }
1392
1393    #[test]
1394    fn test_resolved_ui_config_pager_defaults() {
1395        let defaults = DefaultUserConfig::from_embedded().ui;
1396
1397        let build_target =
1398            Platform::build_target().expect("nextest is built for a supported platform");
1399        let resolved = UiConfig::resolve(&defaults, &[], None, &[], &build_target);
1400
1401        // Resolved values should match the embedded defaults.
1402        assert_eq!(resolved.pager, defaults.pager);
1403        assert_eq!(resolved.paginate, defaults.paginate);
1404    }
1405
1406    #[test]
1407    fn test_resolved_ui_config_pager_override() {
1408        let defaults = DefaultUserConfig::from_embedded().ui;
1409
1410        // Create an override that sets a custom pager.
1411        let custom_pager = PagerSetting::External(CommandNameAndArgs {
1412            command: vec!["more".to_owned()],
1413            env: BTreeMap::new(),
1414        });
1415        let override_ = make_override(
1416            "cfg(all())",
1417            DeserializedUiOverrideData {
1418                pager: Some(custom_pager.clone()),
1419                ..Default::default()
1420            },
1421        );
1422
1423        let build_target =
1424            Platform::build_target().expect("nextest is built for a supported platform");
1425        let resolved = UiConfig::resolve(&defaults, &[], None, &[override_], &build_target);
1426
1427        assert_eq!(resolved.pager, custom_pager);
1428        // paginate should still be from defaults.
1429        assert_eq!(resolved.paginate, defaults.paginate);
1430    }
1431
1432    #[test]
1433    fn test_resolved_ui_config_paginate_override() {
1434        let defaults = DefaultUserConfig::from_embedded().ui;
1435
1436        // Create an override that sets paginate to "never".
1437        let override_ = make_override(
1438            "cfg(all())",
1439            DeserializedUiOverrideData {
1440                paginate: Some(PaginateSetting::Never),
1441                ..Default::default()
1442            },
1443        );
1444
1445        let build_target =
1446            Platform::build_target().expect("nextest is built for a supported platform");
1447        let resolved = UiConfig::resolve(&defaults, &[], None, &[override_], &build_target);
1448
1449        assert_eq!(resolved.paginate, PaginateSetting::Never);
1450        // pager should still be from defaults.
1451        assert_eq!(resolved.pager, defaults.pager);
1452    }
1453
1454    #[test]
1455    fn test_pager_setting_builtin() {
1456        // `:builtin` special string.
1457        let config: DeserializedUiConfig = toml::from_str(r#"pager = ":builtin""#).unwrap();
1458        assert_eq!(config.pager, Some(PagerSetting::Builtin));
1459    }
1460
1461    #[test]
1462    fn test_streampager_config_parsing() {
1463        // Full config.
1464        let config: DeserializedUiConfig = toml::from_str(
1465            r#"
1466            [streampager]
1467            interface = "full-screen-clear-output"
1468            wrapping = "anywhere"
1469            show-ruler = false
1470            "#,
1471        )
1472        .unwrap();
1473        assert_eq!(
1474            config.streampager.interface,
1475            Some(StreampagerInterface::FullScreenClearOutput)
1476        );
1477        assert_eq!(
1478            config.streampager.wrapping,
1479            Some(StreampagerWrapping::Anywhere)
1480        );
1481        assert_eq!(config.streampager.show_ruler, Some(false));
1482
1483        // Partial config - unspecified fields are None.
1484        let config: DeserializedUiConfig = toml::from_str(
1485            r#"
1486            [streampager]
1487            interface = "quit-quickly-or-clear-output"
1488            "#,
1489        )
1490        .unwrap();
1491        assert_eq!(
1492            config.streampager.interface,
1493            Some(StreampagerInterface::QuitQuicklyOrClearOutput)
1494        );
1495        assert_eq!(config.streampager.wrapping, None);
1496        assert_eq!(config.streampager.show_ruler, None);
1497
1498        // Empty config - all fields are None.
1499        let config: DeserializedUiConfig = toml::from_str("").unwrap();
1500        assert_eq!(config.streampager.interface, None);
1501        assert_eq!(config.streampager.wrapping, None);
1502        assert_eq!(config.streampager.show_ruler, None);
1503    }
1504
1505    #[test]
1506    fn test_streampager_config_resolution() {
1507        let defaults = DefaultUserConfig::from_embedded().ui;
1508
1509        // Override just the interface.
1510        let override_ = make_override(
1511            "cfg(all())",
1512            DeserializedUiOverrideData {
1513                streampager: DeserializedStreampagerConfig {
1514                    interface: Some(StreampagerInterface::FullScreenClearOutput),
1515                    wrapping: None,
1516                    show_ruler: None,
1517                },
1518                ..Default::default()
1519            },
1520        );
1521
1522        let build_target =
1523            Platform::build_target().expect("nextest is built for a supported platform");
1524        let resolved = UiConfig::resolve(&defaults, &[], None, &[override_], &build_target);
1525
1526        // Interface should be overridden.
1527        assert_eq!(
1528            resolved.streampager.interface,
1529            StreampagerInterface::FullScreenClearOutput
1530        );
1531        // wrapping and show_ruler should be from defaults.
1532        assert_eq!(resolved.streampager.wrapping, defaults.streampager.wrapping);
1533        assert_eq!(
1534            resolved.streampager.show_ruler,
1535            defaults.streampager.show_ruler
1536        );
1537    }
1538}