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