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 separate from [`ShowProgress`] because the `Only` variant has
329/// special behavior: it implies `--status-level=slow` and
330/// `--final-status-level=none`. This information would be lost if we converted
331/// directly to `ShowProgress`.
332#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
333#[serde(rename_all = "kebab-case")]
334pub enum UiShowProgress {
335    /// Automatically choose based on terminal capabilities.
336    #[default]
337    Auto,
338    /// No progress display.
339    None,
340    /// Show a progress bar with running tests.
341    Bar,
342    /// Show a simple counter (e.g., "(1/10)").
343    Counter,
344    /// Like `Bar`, but also sets `status-level=slow` and
345    /// `final-status-level=none`.
346    Only,
347}
348
349impl From<UiShowProgress> for ShowProgress {
350    fn from(ui: UiShowProgress) -> Self {
351        match ui {
352            UiShowProgress::Auto => ShowProgress::Auto,
353            UiShowProgress::None => ShowProgress::None,
354            UiShowProgress::Bar | UiShowProgress::Only => ShowProgress::Running,
355            UiShowProgress::Counter => ShowProgress::Counter,
356        }
357    }
358}
359
360/// Controls when to paginate output.
361#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
362#[serde(rename_all = "kebab-case")]
363pub enum PaginateSetting {
364    /// Automatically page if stdout is a TTY and output would benefit from it.
365    #[default]
366    Auto,
367    /// Never use a pager.
368    Never,
369}
370
371/// The special string that indicates the builtin pager should be used.
372pub const BUILTIN_PAGER_NAME: &str = ":builtin";
373
374/// Deserialized streampager configuration (all fields optional).
375///
376/// Used in user config and overrides where any field may be unspecified.
377#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
378#[serde(rename_all = "kebab-case")]
379pub(in crate::user_config) struct DeserializedStreampagerConfig {
380    /// Interface mode controlling alternate screen behavior.
381    pub(in crate::user_config) interface: Option<StreampagerInterface>,
382    /// Text wrapping mode.
383    pub(in crate::user_config) wrapping: Option<StreampagerWrapping>,
384    /// Whether to show a ruler at the bottom.
385    pub(in crate::user_config) show_ruler: Option<bool>,
386}
387
388/// Default streampager configuration (all fields required).
389///
390/// Used in the embedded default config.
391#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
392#[serde(rename_all = "kebab-case")]
393pub(in crate::user_config) struct DefaultStreampagerConfig {
394    /// Interface mode controlling alternate screen behavior.
395    pub(in crate::user_config) interface: StreampagerInterface,
396    /// Text wrapping mode.
397    pub(in crate::user_config) wrapping: StreampagerWrapping,
398    /// Whether to show a ruler at the bottom.
399    pub(in crate::user_config) show_ruler: bool,
400}
401
402/// Resolved streampager configuration.
403///
404/// These settings control behavior when `pager = ":builtin"` is configured.
405#[derive(Clone, Copy, Debug, PartialEq, Eq)]
406pub struct StreampagerConfig {
407    /// Interface mode controlling alternate screen behavior.
408    pub interface: StreampagerInterface,
409    /// Text wrapping mode.
410    pub wrapping: StreampagerWrapping,
411    /// Whether to show a ruler at the bottom.
412    pub show_ruler: bool,
413}
414
415impl StreampagerConfig {
416    /// Converts to the streampager library's interface mode.
417    pub fn streampager_interface_mode(&self) -> streampager::config::InterfaceMode {
418        use streampager::config::InterfaceMode;
419        match self.interface {
420            StreampagerInterface::FullScreenClearOutput => InterfaceMode::FullScreen,
421            StreampagerInterface::QuitIfOnePage => InterfaceMode::Hybrid,
422            StreampagerInterface::QuitQuicklyOrClearOutput => {
423                InterfaceMode::Delayed(std::time::Duration::from_secs(2))
424            }
425        }
426    }
427
428    /// Converts to the streampager library's wrapping mode.
429    pub fn streampager_wrapping_mode(&self) -> streampager::config::WrappingMode {
430        use streampager::config::WrappingMode;
431        match self.wrapping {
432            StreampagerWrapping::None => WrappingMode::Unwrapped,
433            StreampagerWrapping::Word => WrappingMode::WordBoundary,
434            StreampagerWrapping::Anywhere => WrappingMode::GraphemeBoundary,
435        }
436    }
437}
438
439/// Interface mode for the builtin streampager.
440///
441/// Controls how the pager uses the alternate screen and when it exits.
442#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
443#[serde(rename_all = "kebab-case")]
444pub enum StreampagerInterface {
445    /// Exit immediately if content fits on one page; otherwise use full screen
446    /// and clear on exit.
447    #[default]
448    QuitIfOnePage,
449    /// Always use full screen mode and clear the screen on exit.
450    FullScreenClearOutput,
451    /// Wait briefly before entering full screen; clear on exit if entered.
452    QuitQuicklyOrClearOutput,
453}
454
455/// Text wrapping mode for the builtin streampager.
456#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
457#[serde(rename_all = "kebab-case")]
458pub enum StreampagerWrapping {
459    /// Do not wrap text; allow horizontal scrolling.
460    None,
461    /// Wrap at word boundaries.
462    #[default]
463    Word,
464    /// Wrap at any character (grapheme) boundary.
465    Anywhere,
466}
467
468/// A command with optional arguments and environment variables.
469///
470/// Supports three input formats, all normalized to the same representation:
471///
472/// - String: `"less -FRX"` (split on whitespace)
473/// - Array: `["less", "-FRX"]`
474/// - Structured: `{ command = ["less", "-FRX"], env = { LESSCHARSET = "utf-8" } }`
475#[derive(Clone, Debug, PartialEq, Eq)]
476pub struct CommandNameAndArgs {
477    /// The command and its arguments (non-empty after deserialization).
478    command: Vec<String>,
479    /// Environment variables to set when running the command.
480    env: BTreeMap<String, String>,
481}
482
483impl CommandNameAndArgs {
484    /// Returns the command name.
485    pub fn command_name(&self) -> &str {
486        // The command is validated to be non-empty during deserialization.
487        &self.command[0]
488    }
489
490    /// Returns the arguments.
491    pub fn args(&self) -> &[String] {
492        &self.command[1..]
493    }
494
495    /// Creates a [`std::process::Command`] from this configuration.
496    pub fn to_command(&self) -> Command {
497        let mut cmd = Command::new(self.command_name());
498        cmd.args(self.args());
499        cmd.envs(&self.env);
500        cmd
501    }
502}
503
504impl<'de> Deserialize<'de> for CommandNameAndArgs {
505    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
506        deserializer.deserialize_any(CommandNameAndArgsVisitor)
507    }
508}
509
510/// Visitor for deserializing CommandNameAndArgs.
511struct CommandNameAndArgsVisitor;
512
513impl<'de> de::Visitor<'de> for CommandNameAndArgsVisitor {
514    type Value = CommandNameAndArgs;
515
516    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
517        formatter.write_str(
518            "a command string (\"less -FRX\"), \
519             an array ([\"less\", \"-FRX\"]), \
520             or a table ({ command = [\"less\", \"-FRX\"], env = { ... } })",
521        )
522    }
523
524    fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
525        let command: Vec<String> = shell_words::split(v).map_err(de::Error::custom)?;
526        if command.is_empty() {
527            return Err(de::Error::custom("command string must not be empty"));
528        }
529        Ok(CommandNameAndArgs {
530            command,
531            env: BTreeMap::new(),
532        })
533    }
534
535    fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
536        let mut command = Vec::new();
537        while let Some(arg) = seq.next_element::<String>()? {
538            command.push(arg);
539        }
540        if command.is_empty() {
541            return Err(de::Error::custom("command array must not be empty"));
542        }
543        Ok(CommandNameAndArgs {
544            command,
545            env: BTreeMap::new(),
546        })
547    }
548
549    fn visit_map<A: de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
550        #[derive(Deserialize)]
551        struct StructuredInner {
552            command: Vec<String>,
553            #[serde(default)]
554            env: BTreeMap<String, String>,
555        }
556
557        let inner = StructuredInner::deserialize(de::value::MapAccessDeserializer::new(map))?;
558        if inner.command.is_empty() {
559            return Err(de::Error::custom("command array must not be empty"));
560        }
561        Ok(CommandNameAndArgs {
562            command: inner.command,
563            env: inner.env,
564        })
565    }
566}
567
568/// Controls which pager to use for output that benefits from scrolling.
569///
570/// This specifies *which* pager to use; whether to actually paginate is
571/// controlled by [`PaginateSetting`].
572#[derive(Clone, Debug, PartialEq, Eq)]
573pub enum PagerSetting {
574    /// Use the builtin streampager.
575    Builtin,
576    /// Use an external command.
577    External(CommandNameAndArgs),
578}
579
580// Only used in unit tests -- in regular code, the default is looked up via
581// default-user-config.toml.
582#[cfg(test)]
583impl Default for PagerSetting {
584    fn default() -> Self {
585        Self::External(CommandNameAndArgs {
586            command: vec!["less".to_owned(), "-FRX".to_owned()],
587            env: [("LESSCHARSET".to_owned(), "utf-8".to_owned())]
588                .into_iter()
589                .collect(),
590        })
591    }
592}
593
594impl<'de> Deserialize<'de> for PagerSetting {
595    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
596        deserializer.deserialize_any(PagerSettingVisitor)
597    }
598}
599
600/// Visitor for deserializing PagerSetting.
601struct PagerSettingVisitor;
602
603impl<'de> de::Visitor<'de> for PagerSettingVisitor {
604    type Value = PagerSetting;
605
606    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
607        formatter
608            .write_str("\":builtin\", a command string, an array, or a table with command and env")
609    }
610
611    fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
612        // Check for the special ":builtin" value.
613        if v == BUILTIN_PAGER_NAME {
614            return Ok(PagerSetting::Builtin);
615        }
616        let cmd = CommandNameAndArgsVisitor.visit_str(v)?;
617        Ok(PagerSetting::External(cmd))
618    }
619
620    fn visit_seq<A: de::SeqAccess<'de>>(self, seq: A) -> Result<Self::Value, A::Error> {
621        let args = CommandNameAndArgsVisitor.visit_seq(seq)?;
622        Ok(PagerSetting::External(args))
623    }
624
625    fn visit_map<A: de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
626        let args = CommandNameAndArgsVisitor.visit_map(map)?;
627        Ok(PagerSetting::External(args))
628    }
629}
630
631/// Visitor for deserializing max-progress-running (string or integer).
632struct MaxProgressRunningVisitor;
633
634impl<'de> de::Visitor<'de> for MaxProgressRunningVisitor {
635    type Value = MaxProgressRunning;
636
637    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
638        formatter.write_str("a non-negative integer or \"infinite\"")
639    }
640
641    fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
642        Ok(MaxProgressRunning::Count(v as usize))
643    }
644
645    fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
646        if v < 0 {
647            Err(E::invalid_value(Unexpected::Signed(v), &self))
648        } else {
649            Ok(MaxProgressRunning::Count(v as usize))
650        }
651    }
652
653    fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
654        if v == "infinite" {
655            Ok(MaxProgressRunning::Infinite)
656        } else {
657            // Try parsing as a number.
658            v.parse::<usize>()
659                .map(MaxProgressRunning::Count)
660                .map_err(|_| E::invalid_value(Unexpected::Str(v), &self))
661        }
662    }
663}
664
665fn deserialize_max_progress_running<'de, D>(
666    deserializer: D,
667) -> Result<Option<MaxProgressRunning>, D::Error>
668where
669    D: Deserializer<'de>,
670{
671    deserializer.deserialize_option(OptionMaxProgressRunningVisitor)
672}
673
674/// Visitor for deserializing Option<MaxProgressRunning>.
675struct OptionMaxProgressRunningVisitor;
676
677impl<'de> de::Visitor<'de> for OptionMaxProgressRunningVisitor {
678    type Value = Option<MaxProgressRunning>;
679
680    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
681        formatter.write_str("a non-negative integer, \"infinite\", or null")
682    }
683
684    fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
685        Ok(None)
686    }
687
688    fn visit_some<D: Deserializer<'de>>(self, deserializer: D) -> Result<Self::Value, D::Error> {
689        deserializer
690            .deserialize_any(MaxProgressRunningVisitor)
691            .map(Some)
692    }
693
694    fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
695        Ok(None)
696    }
697}
698
699fn deserialize_max_progress_running_required<'de, D>(
700    deserializer: D,
701) -> Result<MaxProgressRunning, D::Error>
702where
703    D: Deserializer<'de>,
704{
705    deserializer.deserialize_any(MaxProgressRunningVisitor)
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use crate::{platform::detect_host_platform_for_tests, user_config::DefaultUserConfig};
712
713    /// Helper to create a CompiledUiOverride for tests.
714    fn make_override(platform: &str, data: DeserializedUiOverrideData) -> CompiledUiOverride {
715        let platform_spec =
716            TargetSpec::new(platform.to_string()).expect("valid platform spec in test");
717        CompiledUiOverride::new(platform_spec, data)
718    }
719
720    #[test]
721    fn test_ui_config_show_progress() {
722        // Test valid values.
723        let config: DeserializedUiConfig = toml::from_str(r#"show-progress = "auto""#).unwrap();
724        assert!(matches!(config.show_progress, Some(UiShowProgress::Auto)));
725
726        let config: DeserializedUiConfig = toml::from_str(r#"show-progress = "none""#).unwrap();
727        assert!(matches!(config.show_progress, Some(UiShowProgress::None)));
728
729        let config: DeserializedUiConfig = toml::from_str(r#"show-progress = "bar""#).unwrap();
730        assert!(matches!(config.show_progress, Some(UiShowProgress::Bar)));
731
732        let config: DeserializedUiConfig = toml::from_str(r#"show-progress = "counter""#).unwrap();
733        assert!(matches!(
734            config.show_progress,
735            Some(UiShowProgress::Counter)
736        ));
737
738        let config: DeserializedUiConfig = toml::from_str(r#"show-progress = "only""#).unwrap();
739        assert!(matches!(config.show_progress, Some(UiShowProgress::Only)));
740
741        // Test missing value.
742        let config: DeserializedUiConfig = toml::from_str("").unwrap();
743        assert!(config.show_progress.is_none());
744
745        // Test invalid value.
746        toml::from_str::<DeserializedUiConfig>(r#"show-progress = "invalid""#).unwrap_err();
747    }
748
749    #[test]
750    fn test_ui_show_progress_to_show_progress() {
751        // Test conversion to ShowProgress.
752        assert_eq!(ShowProgress::from(UiShowProgress::Auto), ShowProgress::Auto);
753        assert_eq!(ShowProgress::from(UiShowProgress::None), ShowProgress::None);
754        assert_eq!(
755            ShowProgress::from(UiShowProgress::Bar),
756            ShowProgress::Running
757        );
758        assert_eq!(
759            ShowProgress::from(UiShowProgress::Counter),
760            ShowProgress::Counter
761        );
762        // Only maps to Running (special behavior handled separately).
763        assert_eq!(
764            ShowProgress::from(UiShowProgress::Only),
765            ShowProgress::Running
766        );
767    }
768
769    #[test]
770    fn test_ui_config_max_progress_running() {
771        // Test integer values.
772        let config: DeserializedUiConfig = toml::from_str("max-progress-running = 10").unwrap();
773        assert!(matches!(
774            config.max_progress_running,
775            Some(MaxProgressRunning::Count(10))
776        ));
777
778        let config: DeserializedUiConfig = toml::from_str("max-progress-running = 0").unwrap();
779        assert!(matches!(
780            config.max_progress_running,
781            Some(MaxProgressRunning::Count(0))
782        ));
783
784        // Test string "infinite".
785        let config: DeserializedUiConfig =
786            toml::from_str(r#"max-progress-running = "infinite""#).unwrap();
787        assert!(matches!(
788            config.max_progress_running,
789            Some(MaxProgressRunning::Infinite)
790        ));
791
792        // Test that matching is case-sensitive.
793        toml::from_str::<DeserializedUiConfig>(r#"max-progress-running = "INFINITE""#).unwrap_err();
794
795        // Test missing value.
796        let config: DeserializedUiConfig = toml::from_str("").unwrap();
797        assert!(config.max_progress_running.is_none());
798
799        // Test invalid value.
800        toml::from_str::<DeserializedUiConfig>(r#"max-progress-running = "invalid""#).unwrap_err();
801    }
802
803    #[test]
804    fn test_ui_config_input_handler() {
805        let config: DeserializedUiConfig = toml::from_str("input-handler = true").unwrap();
806        assert_eq!(config.input_handler, Some(true));
807        let config: DeserializedUiConfig = toml::from_str("input-handler = false").unwrap();
808        assert_eq!(config.input_handler, Some(false));
809        let config: DeserializedUiConfig = toml::from_str("").unwrap();
810        assert!(config.input_handler.is_none());
811    }
812
813    #[test]
814    fn test_ui_config_output_indent() {
815        let config: DeserializedUiConfig = toml::from_str("output-indent = true").unwrap();
816        assert_eq!(config.output_indent, Some(true));
817        let config: DeserializedUiConfig = toml::from_str("output-indent = false").unwrap();
818        assert_eq!(config.output_indent, Some(false));
819        let config: DeserializedUiConfig = toml::from_str("").unwrap();
820        assert!(config.output_indent.is_none());
821    }
822
823    #[test]
824    fn test_resolved_ui_config_defaults_only() {
825        let defaults = DefaultUserConfig::from_embedded().ui;
826
827        let host = detect_host_platform_for_tests();
828        let resolved = UiConfig::resolve(&defaults, &[], None, &[], &host);
829
830        // Resolved values should match the embedded defaults.
831        assert_eq!(resolved.show_progress, defaults.show_progress);
832        assert_eq!(resolved.max_progress_running, defaults.max_progress_running);
833        assert_eq!(resolved.input_handler, defaults.input_handler);
834        assert_eq!(resolved.output_indent, defaults.output_indent);
835    }
836
837    #[test]
838    fn test_resolved_ui_config_user_config_overrides_defaults() {
839        let defaults = DefaultUserConfig::from_embedded().ui;
840
841        let user_config = DeserializedUiConfig {
842            show_progress: Some(UiShowProgress::Bar),
843            max_progress_running: Some(MaxProgressRunning::Count(4)),
844            output_indent: Some(false),
845            ..Default::default()
846        };
847
848        let host = detect_host_platform_for_tests();
849        let resolved = UiConfig::resolve(&defaults, &[], Some(&user_config), &[], &host);
850
851        assert_eq!(resolved.show_progress, UiShowProgress::Bar);
852        assert_eq!(resolved.max_progress_running, MaxProgressRunning::Count(4));
853        assert_eq!(resolved.input_handler, defaults.input_handler); // From defaults.
854        assert!(!resolved.output_indent);
855    }
856
857    #[test]
858    fn test_resolved_ui_config_user_override_applies() {
859        let defaults = DefaultUserConfig::from_embedded().ui;
860
861        // Create a user override that matches any platform.
862        let override_ = make_override(
863            "cfg(all())",
864            DeserializedUiOverrideData {
865                show_progress: Some(UiShowProgress::Counter),
866                input_handler: Some(false),
867                ..Default::default()
868            },
869        );
870
871        let host = detect_host_platform_for_tests();
872        let resolved = UiConfig::resolve(&defaults, &[], None, &[override_], &host);
873
874        assert_eq!(resolved.show_progress, UiShowProgress::Counter);
875        assert_eq!(resolved.max_progress_running, defaults.max_progress_running); // From defaults.
876        assert!(!resolved.input_handler);
877        assert_eq!(resolved.output_indent, defaults.output_indent); // From defaults.
878    }
879
880    #[test]
881    fn test_resolved_ui_config_default_override_applies() {
882        let defaults = DefaultUserConfig::from_embedded().ui;
883
884        // Create a default override that matches any platform.
885        let override_ = make_override(
886            "cfg(all())",
887            DeserializedUiOverrideData {
888                show_progress: Some(UiShowProgress::Counter),
889                input_handler: Some(false),
890                ..Default::default()
891            },
892        );
893
894        let host = detect_host_platform_for_tests();
895        let resolved = UiConfig::resolve(&defaults, &[override_], None, &[], &host);
896
897        assert_eq!(resolved.show_progress, UiShowProgress::Counter);
898        assert_eq!(resolved.max_progress_running, defaults.max_progress_running); // From defaults.
899        assert!(!resolved.input_handler);
900        assert_eq!(resolved.output_indent, defaults.output_indent); // From defaults.
901    }
902
903    #[test]
904    fn test_resolved_ui_config_platform_override_no_match() {
905        let defaults = DefaultUserConfig::from_embedded().ui;
906
907        // Create an override that never matches (cfg(any()) with no arguments
908        // is false).
909        let override_ = make_override(
910            "cfg(any())",
911            DeserializedUiOverrideData {
912                show_progress: Some(UiShowProgress::Counter),
913                max_progress_running: Some(MaxProgressRunning::Count(2)),
914                input_handler: Some(false),
915                output_indent: Some(false),
916                pager: Some(PagerSetting::default()),
917                paginate: Some(PaginateSetting::Never),
918                streampager: Default::default(),
919            },
920        );
921
922        let host = detect_host_platform_for_tests();
923        let resolved = UiConfig::resolve(&defaults, &[], None, &[override_], &host);
924
925        // Nothing should be overridden - all values should match defaults.
926        assert_eq!(resolved.show_progress, defaults.show_progress);
927        assert_eq!(resolved.max_progress_running, defaults.max_progress_running);
928        assert_eq!(resolved.input_handler, defaults.input_handler);
929        assert_eq!(resolved.output_indent, defaults.output_indent);
930    }
931
932    #[test]
933    fn test_resolved_ui_config_first_matching_user_override_wins() {
934        let defaults = DefaultUserConfig::from_embedded().ui;
935
936        // Create two user overrides that both match (cfg(all()) is always true).
937        let override1 = make_override(
938            "cfg(all())",
939            DeserializedUiOverrideData {
940                show_progress: Some(UiShowProgress::Bar),
941                ..Default::default()
942            },
943        );
944
945        let override2 = make_override(
946            "cfg(all())",
947            DeserializedUiOverrideData {
948                show_progress: Some(UiShowProgress::Counter), // Should be ignored.
949                max_progress_running: Some(MaxProgressRunning::Count(4)),
950                ..Default::default()
951            },
952        );
953
954        let host = detect_host_platform_for_tests();
955        let resolved = UiConfig::resolve(&defaults, &[], None, &[override1, override2], &host);
956
957        // First override wins for show_progress.
958        assert_eq!(resolved.show_progress, UiShowProgress::Bar);
959        // Second override's max_progress_running applies (first didn't set it).
960        assert_eq!(resolved.max_progress_running, MaxProgressRunning::Count(4));
961    }
962
963    #[test]
964    fn test_resolved_ui_config_user_override_beats_default_override() {
965        let defaults = DefaultUserConfig::from_embedded().ui;
966
967        // User override sets show_progress.
968        let user_override = make_override(
969            "cfg(all())",
970            DeserializedUiOverrideData {
971                show_progress: Some(UiShowProgress::Bar),
972                ..Default::default()
973            },
974        );
975
976        // Default override sets show_progress and max_progress_running.
977        let default_override = make_override(
978            "cfg(all())",
979            DeserializedUiOverrideData {
980                show_progress: Some(UiShowProgress::Counter), // Should be ignored.
981                max_progress_running: Some(MaxProgressRunning::Count(4)),
982                ..Default::default()
983            },
984        );
985
986        let host = detect_host_platform_for_tests();
987        let resolved = UiConfig::resolve(
988            &defaults,
989            &[default_override],
990            None,
991            &[user_override],
992            &host,
993        );
994
995        // User override wins for show_progress.
996        assert_eq!(resolved.show_progress, UiShowProgress::Bar);
997        // Default override applies for max_progress_running (user didn't set it).
998        assert_eq!(resolved.max_progress_running, MaxProgressRunning::Count(4));
999    }
1000
1001    #[test]
1002    fn test_resolved_ui_config_override_beats_user_base() {
1003        let defaults = DefaultUserConfig::from_embedded().ui;
1004
1005        // User base config sets show_progress.
1006        let user_config = DeserializedUiConfig {
1007            show_progress: Some(UiShowProgress::None),
1008            max_progress_running: Some(MaxProgressRunning::Count(2)),
1009            ..Default::default()
1010        };
1011
1012        // Default override sets show_progress (should beat user base).
1013        let default_override = make_override(
1014            "cfg(all())",
1015            DeserializedUiOverrideData {
1016                show_progress: Some(UiShowProgress::Counter),
1017                ..Default::default()
1018            },
1019        );
1020
1021        let host = detect_host_platform_for_tests();
1022        let resolved = UiConfig::resolve(
1023            &defaults,
1024            &[default_override],
1025            Some(&user_config),
1026            &[],
1027            &host,
1028        );
1029
1030        // Default override is chosen over user base for show_progress.
1031        assert_eq!(resolved.show_progress, UiShowProgress::Counter);
1032        // User base applies for max_progress_running (override didn't set it).
1033        assert_eq!(resolved.max_progress_running, MaxProgressRunning::Count(2));
1034    }
1035
1036    #[test]
1037    fn test_paginate_setting_parsing() {
1038        // Test "auto".
1039        let config: DeserializedUiConfig = toml::from_str(r#"paginate = "auto""#).unwrap();
1040        assert_eq!(config.paginate, Some(PaginateSetting::Auto));
1041
1042        // Test "never".
1043        let config: DeserializedUiConfig = toml::from_str(r#"paginate = "never""#).unwrap();
1044        assert_eq!(config.paginate, Some(PaginateSetting::Never));
1045
1046        // Test missing value.
1047        let config: DeserializedUiConfig = toml::from_str("").unwrap();
1048        assert!(config.paginate.is_none());
1049
1050        // Test invalid value.
1051        let err = toml::from_str::<DeserializedUiConfig>(r#"paginate = "invalid""#).unwrap_err();
1052        assert!(
1053            err.to_string().contains("unknown variant"),
1054            "error should mention 'unknown variant': {err}"
1055        );
1056    }
1057
1058    #[test]
1059    fn test_command_name_and_args_parsing() {
1060        #[derive(Debug, Deserialize)]
1061        struct Wrapper {
1062            cmd: CommandNameAndArgs,
1063        }
1064
1065        // String format: split using shell word parsing.
1066        let wrapper: Wrapper = toml::from_str(r#"cmd = "less -FRX""#).unwrap();
1067        assert_eq!(
1068            wrapper.cmd,
1069            CommandNameAndArgs {
1070                command: vec!["less".to_owned(), "-FRX".to_owned()],
1071                env: BTreeMap::new(),
1072            }
1073        );
1074        assert_eq!(wrapper.cmd.command_name(), "less");
1075        assert_eq!(wrapper.cmd.args(), &["-FRX".to_owned()]);
1076
1077        // Array format: each element is a separate argument.
1078        let wrapper: Wrapper = toml::from_str(r#"cmd = ["less", "-F", "-R", "-X"]"#).unwrap();
1079        assert_eq!(
1080            wrapper.cmd,
1081            CommandNameAndArgs {
1082                command: vec![
1083                    "less".to_owned(),
1084                    "-F".to_owned(),
1085                    "-R".to_owned(),
1086                    "-X".to_owned()
1087                ],
1088                env: BTreeMap::new(),
1089            }
1090        );
1091        assert_eq!(wrapper.cmd.command_name(), "less");
1092        assert_eq!(
1093            wrapper.cmd.args(),
1094            &["-F".to_owned(), "-R".to_owned(), "-X".to_owned()]
1095        );
1096
1097        // Structured format: command array with optional env.
1098        let cmd: CommandNameAndArgs = toml::from_str(
1099            r#"
1100            command = ["less", "-FRX"]
1101            env = { LESSCHARSET = "utf-8" }
1102            "#,
1103        )
1104        .unwrap();
1105        let expected_env: BTreeMap<String, String> =
1106            [("LESSCHARSET".to_owned(), "utf-8".to_owned())]
1107                .into_iter()
1108                .collect();
1109        assert_eq!(
1110            cmd,
1111            CommandNameAndArgs {
1112                command: vec!["less".to_owned(), "-FRX".to_owned()],
1113                env: expected_env,
1114            }
1115        );
1116        assert_eq!(cmd.command_name(), "less");
1117        assert_eq!(cmd.args(), &["-FRX".to_owned()]);
1118
1119        // Shell quoting: double quotes preserve spaces.
1120        let wrapper: Wrapper = toml::from_str(r#"cmd = 'my-pager "arg with spaces"'"#).unwrap();
1121        assert_eq!(
1122            wrapper.cmd,
1123            CommandNameAndArgs {
1124                command: vec!["my-pager".to_owned(), "arg with spaces".to_owned()],
1125                env: BTreeMap::new(),
1126            }
1127        );
1128
1129        // Shell quoting: single quotes preserve spaces.
1130        let wrapper: Wrapper = toml::from_str(r#"cmd = "my-pager 'arg with spaces'""#).unwrap();
1131        assert_eq!(
1132            wrapper.cmd,
1133            CommandNameAndArgs {
1134                command: vec!["my-pager".to_owned(), "arg with spaces".to_owned()],
1135                env: BTreeMap::new(),
1136            }
1137        );
1138
1139        // Shell quoting: escaped quotes within double quotes.
1140        let wrapper: Wrapper =
1141            toml::from_str(r#"cmd = 'my-pager "quoted \"nested\" arg"'"#).unwrap();
1142        assert_eq!(
1143            wrapper.cmd,
1144            CommandNameAndArgs {
1145                command: vec!["my-pager".to_owned(), "quoted \"nested\" arg".to_owned()],
1146                env: BTreeMap::new(),
1147            }
1148        );
1149
1150        // Shell quoting: path with spaces.
1151        let wrapper: Wrapper = toml::from_str(r#"cmd = '"/path/to/my pager" --flag'"#).unwrap();
1152        assert_eq!(
1153            wrapper.cmd,
1154            CommandNameAndArgs {
1155                command: vec!["/path/to/my pager".to_owned(), "--flag".to_owned()],
1156                env: BTreeMap::new(),
1157            }
1158        );
1159
1160        // Shell quoting: multiple quoted arguments.
1161        let wrapper: Wrapper =
1162            toml::from_str(r#"cmd = 'cmd "first arg" "second arg" third'"#).unwrap();
1163        assert_eq!(
1164            wrapper.cmd,
1165            CommandNameAndArgs {
1166                command: vec![
1167                    "cmd".to_owned(),
1168                    "first arg".to_owned(),
1169                    "second arg".to_owned(),
1170                    "third".to_owned(),
1171                ],
1172                env: BTreeMap::new(),
1173            }
1174        );
1175    }
1176
1177    #[test]
1178    fn test_command_and_pager_empty_errors() {
1179        #[derive(Debug, Deserialize)]
1180        struct Wrapper {
1181            #[expect(dead_code)]
1182            cmd: CommandNameAndArgs,
1183        }
1184
1185        // Test CommandNameAndArgs empty cases.
1186        let cmd_cases = [
1187            ("empty array", "cmd = []"),
1188            ("empty string", r#"cmd = """#),
1189            ("whitespace-only string", r#"cmd = "   ""#),
1190            (
1191                "structured with empty command",
1192                r#"cmd = { command = [], env = { LESSCHARSET = "utf-8" } }"#,
1193            ),
1194        ];
1195
1196        for (name, input) in cmd_cases {
1197            let err = toml::from_str::<Wrapper>(input).unwrap_err();
1198            assert!(
1199                err.to_string().contains("must not be empty"),
1200                "CommandNameAndArgs {name}: error should mention 'must not be empty': {err}"
1201            );
1202        }
1203
1204        // Test PagerSetting empty cases (via DeserializedUiConfig).
1205        let pager_cases = [
1206            ("empty array", "pager = []"),
1207            ("empty string", r#"pager = """#),
1208        ];
1209
1210        for (name, input) in pager_cases {
1211            let err = toml::from_str::<DeserializedUiConfig>(input).unwrap_err();
1212            assert!(
1213                err.to_string().contains("must not be empty"),
1214                "PagerSetting {name}: error should mention 'must not be empty': {err}"
1215            );
1216        }
1217
1218        // Test invalid shell quoting (unclosed quotes).
1219        let unclosed_quote_cases = [
1220            ("unclosed double quote", r#"cmd = 'pager "unclosed'"#),
1221            ("unclosed single quote", r#"cmd = "pager 'unclosed""#),
1222        ];
1223
1224        for (name, input) in unclosed_quote_cases {
1225            let err = toml::from_str::<Wrapper>(input).unwrap_err();
1226            assert!(
1227                err.to_string().contains("missing closing quote"),
1228                "CommandNameAndArgs {name}: error should mention 'missing closing quote': {err}"
1229            );
1230        }
1231    }
1232
1233    #[test]
1234    fn test_command_name_and_args_to_command() {
1235        // Test that to_command produces a valid Command.
1236        let cmd = CommandNameAndArgs {
1237            command: vec!["echo".to_owned(), "hello".to_owned()],
1238            env: BTreeMap::new(),
1239        };
1240        let std_cmd = cmd.to_command();
1241        assert_eq!(cmd.command_name(), "echo");
1242        drop(std_cmd);
1243    }
1244
1245    #[test]
1246    fn test_pager_setting_parsing() {
1247        // String format.
1248        let config: DeserializedUiConfig = toml::from_str(r#"pager = "less -FRX""#).unwrap();
1249        assert_eq!(
1250            config.pager,
1251            Some(PagerSetting::External(CommandNameAndArgs {
1252                command: vec!["less".to_owned(), "-FRX".to_owned()],
1253                env: BTreeMap::new(),
1254            }))
1255        );
1256
1257        // Array format.
1258        let config: DeserializedUiConfig = toml::from_str(r#"pager = ["less", "-FRX"]"#).unwrap();
1259        assert_eq!(
1260            config.pager,
1261            Some(PagerSetting::External(CommandNameAndArgs {
1262                command: vec!["less".to_owned(), "-FRX".to_owned()],
1263                env: BTreeMap::new(),
1264            }))
1265        );
1266
1267        // Structured format with env.
1268        let config: DeserializedUiConfig = toml::from_str(
1269            r#"
1270            [pager]
1271            command = ["less", "-FRX"]
1272            env = { LESSCHARSET = "utf-8" }
1273            "#,
1274        )
1275        .unwrap();
1276        let expected_env: BTreeMap<String, String> =
1277            [("LESSCHARSET".to_owned(), "utf-8".to_owned())]
1278                .into_iter()
1279                .collect();
1280        assert_eq!(
1281            config.pager,
1282            Some(PagerSetting::External(CommandNameAndArgs {
1283                command: vec!["less".to_owned(), "-FRX".to_owned()],
1284                env: expected_env,
1285            }))
1286        );
1287
1288        // Missing pager (None).
1289        let config: DeserializedUiConfig = toml::from_str("").unwrap();
1290        assert!(config.pager.is_none());
1291    }
1292
1293    #[test]
1294    fn test_resolved_ui_config_pager_defaults() {
1295        let defaults = DefaultUserConfig::from_embedded().ui;
1296
1297        let host = detect_host_platform_for_tests();
1298        let resolved = UiConfig::resolve(&defaults, &[], None, &[], &host);
1299
1300        // Resolved values should match the embedded defaults.
1301        assert_eq!(resolved.pager, defaults.pager);
1302        assert_eq!(resolved.paginate, defaults.paginate);
1303    }
1304
1305    #[test]
1306    fn test_resolved_ui_config_pager_override() {
1307        let defaults = DefaultUserConfig::from_embedded().ui;
1308
1309        // Create an override that sets a custom pager.
1310        let custom_pager = PagerSetting::External(CommandNameAndArgs {
1311            command: vec!["more".to_owned()],
1312            env: BTreeMap::new(),
1313        });
1314        let override_ = make_override(
1315            "cfg(all())",
1316            DeserializedUiOverrideData {
1317                pager: Some(custom_pager.clone()),
1318                ..Default::default()
1319            },
1320        );
1321
1322        let host = detect_host_platform_for_tests();
1323        let resolved = UiConfig::resolve(&defaults, &[], None, &[override_], &host);
1324
1325        assert_eq!(resolved.pager, custom_pager);
1326        // paginate should still be from defaults.
1327        assert_eq!(resolved.paginate, defaults.paginate);
1328    }
1329
1330    #[test]
1331    fn test_resolved_ui_config_paginate_override() {
1332        let defaults = DefaultUserConfig::from_embedded().ui;
1333
1334        // Create an override that sets paginate to "never".
1335        let override_ = make_override(
1336            "cfg(all())",
1337            DeserializedUiOverrideData {
1338                paginate: Some(PaginateSetting::Never),
1339                ..Default::default()
1340            },
1341        );
1342
1343        let host = detect_host_platform_for_tests();
1344        let resolved = UiConfig::resolve(&defaults, &[], None, &[override_], &host);
1345
1346        assert_eq!(resolved.paginate, PaginateSetting::Never);
1347        // pager should still be from defaults.
1348        assert_eq!(resolved.pager, defaults.pager);
1349    }
1350
1351    #[test]
1352    fn test_pager_setting_builtin() {
1353        // `:builtin` special string.
1354        let config: DeserializedUiConfig = toml::from_str(r#"pager = ":builtin""#).unwrap();
1355        assert_eq!(config.pager, Some(PagerSetting::Builtin));
1356    }
1357
1358    #[test]
1359    fn test_streampager_config_parsing() {
1360        // Full config.
1361        let config: DeserializedUiConfig = toml::from_str(
1362            r#"
1363            [streampager]
1364            interface = "full-screen-clear-output"
1365            wrapping = "anywhere"
1366            show-ruler = false
1367            "#,
1368        )
1369        .unwrap();
1370        assert_eq!(
1371            config.streampager.interface,
1372            Some(StreampagerInterface::FullScreenClearOutput)
1373        );
1374        assert_eq!(
1375            config.streampager.wrapping,
1376            Some(StreampagerWrapping::Anywhere)
1377        );
1378        assert_eq!(config.streampager.show_ruler, Some(false));
1379
1380        // Partial config - unspecified fields are None.
1381        let config: DeserializedUiConfig = toml::from_str(
1382            r#"
1383            [streampager]
1384            interface = "quit-quickly-or-clear-output"
1385            "#,
1386        )
1387        .unwrap();
1388        assert_eq!(
1389            config.streampager.interface,
1390            Some(StreampagerInterface::QuitQuicklyOrClearOutput)
1391        );
1392        assert_eq!(config.streampager.wrapping, None);
1393        assert_eq!(config.streampager.show_ruler, None);
1394
1395        // Empty config - all fields are None.
1396        let config: DeserializedUiConfig = toml::from_str("").unwrap();
1397        assert_eq!(config.streampager.interface, None);
1398        assert_eq!(config.streampager.wrapping, None);
1399        assert_eq!(config.streampager.show_ruler, None);
1400    }
1401
1402    #[test]
1403    fn test_streampager_config_resolution() {
1404        let defaults = DefaultUserConfig::from_embedded().ui;
1405
1406        // Override just the interface.
1407        let override_ = make_override(
1408            "cfg(all())",
1409            DeserializedUiOverrideData {
1410                streampager: DeserializedStreampagerConfig {
1411                    interface: Some(StreampagerInterface::FullScreenClearOutput),
1412                    wrapping: None,
1413                    show_ruler: None,
1414                },
1415                ..Default::default()
1416            },
1417        );
1418
1419        let host = detect_host_platform_for_tests();
1420        let resolved = UiConfig::resolve(&defaults, &[], None, &[override_], &host);
1421
1422        // Interface should be overridden.
1423        assert_eq!(
1424            resolved.streampager.interface,
1425            StreampagerInterface::FullScreenClearOutput
1426        );
1427        // wrapping and show_ruler should be from defaults.
1428        assert_eq!(resolved.streampager.wrapping, defaults.streampager.wrapping);
1429        assert_eq!(
1430            resolved.streampager.show_ruler,
1431            defaults.streampager.show_ruler
1432        );
1433    }
1434}