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