Skip to main content

osp_cli/ui/settings/
mod.rs

1use crate::config::{ConfigSource, ConfigValue, ResolvedConfig, Scope};
2use crate::core::output::{ColorMode, OutputFormat, RenderMode, UnicodeMode};
3use crate::core::output_model::{
4    OutputItems, OutputResult, RenderRecommendation, output_items_to_rows,
5};
6use crate::ui::section_chrome::{RuledSectionPolicy, SectionFrameStyle};
7use crate::ui::style;
8use crate::ui::theme;
9use crate::ui::theme::{DEFAULT_THEME_NAME, ThemeDefinition};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum GuideDefaultFormat {
13    #[default]
14    Guide,
15    Inherit,
16}
17
18impl GuideDefaultFormat {
19    pub fn parse(value: &str) -> Option<Self> {
20        match value.trim().to_ascii_lowercase().as_str() {
21            "guide" => Some(Self::Guide),
22            "inherit" | "none" => Some(Self::Inherit),
23            _ => None,
24        }
25    }
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum RenderBackend {
30    Plain,
31    Rich,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum TableBorderStyle {
36    None,
37    #[default]
38    Square,
39    Round,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum TableOverflow {
44    None,
45    Clip,
46    Ellipsis,
47    Wrap,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum HelpLayout {
52    #[default]
53    Full,
54    Compact,
55    Minimal,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum UiPresentation {
60    Expressive,
61    Compact,
62    Austere,
63}
64
65#[derive(Debug, Clone, PartialEq)]
66pub struct PresentationEffect {
67    pub preset: UiPresentation,
68    pub preset_source: ConfigSource,
69    pub preset_scope: Scope,
70    pub preset_origin: Option<String>,
71    pub seeded_value: ConfigValue,
72}
73
74impl UiPresentation {
75    pub fn parse(value: &str) -> Option<Self> {
76        match value.trim().to_ascii_lowercase().as_str() {
77            "expressive" => Some(Self::Expressive),
78            "compact" => Some(Self::Compact),
79            "austere" | "gammel-og-bitter" => Some(Self::Austere),
80            _ => None,
81        }
82    }
83
84    pub fn as_config_value(self) -> &'static str {
85        match self {
86            Self::Expressive => "expressive",
87            Self::Compact => "compact",
88            Self::Austere => "austere",
89        }
90    }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94pub struct HelpChromeSettings {
95    pub table_chrome: HelpTableChrome,
96    pub entry_indent: Option<usize>,
97    pub entry_gap: Option<usize>,
98    pub section_spacing: Option<usize>,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
102pub enum HelpTableChrome {
103    Inherit,
104    #[default]
105    None,
106    Square,
107    Round,
108}
109
110impl HelpTableChrome {
111    pub fn parse(value: &str) -> Option<Self> {
112        match value.trim().to_ascii_lowercase().as_str() {
113            "inherit" => Some(Self::Inherit),
114            "none" | "plain" => Some(Self::None),
115            "square" | "box" | "boxed" => Some(Self::Square),
116            "round" | "rounded" => Some(Self::Round),
117            _ => None,
118        }
119    }
120
121    pub fn resolve(self, table_border: TableBorderStyle) -> TableBorderStyle {
122        match self {
123            Self::Inherit => table_border,
124            Self::None => TableBorderStyle::None,
125            Self::Square => TableBorderStyle::Square,
126            Self::Round => TableBorderStyle::Round,
127        }
128    }
129}
130
131#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct RenderRuntime {
133    pub stdout_is_tty: bool,
134    pub terminal: Option<String>,
135    pub no_color: bool,
136    pub width: Option<usize>,
137    pub locale_utf8: Option<bool>,
138}
139
140impl RenderRuntime {}
141
142impl RenderRuntime {
143    pub fn builder() -> RenderRuntimeBuilder {
144        RenderRuntimeBuilder::default()
145    }
146}
147
148#[derive(Debug, Clone, Default)]
149pub struct RenderRuntimeBuilder {
150    runtime: RenderRuntime,
151}
152
153impl RenderRuntimeBuilder {
154    pub fn with_stdout_is_tty(mut self, stdout_is_tty: bool) -> Self {
155        self.runtime.stdout_is_tty = stdout_is_tty;
156        self
157    }
158
159    pub fn with_terminal(mut self, terminal: impl Into<String>) -> Self {
160        self.runtime.terminal = Some(terminal.into());
161        self
162    }
163
164    pub fn with_no_color(mut self, no_color: bool) -> Self {
165        self.runtime.no_color = no_color;
166        self
167    }
168
169    pub fn with_width(mut self, width: usize) -> Self {
170        self.runtime.width = Some(width);
171        self
172    }
173
174    pub fn with_locale_utf8(mut self, locale_utf8: bool) -> Self {
175        self.runtime.locale_utf8 = Some(locale_utf8);
176        self
177    }
178
179    pub fn build(self) -> RenderRuntime {
180        self.runtime
181    }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct RenderSettings {
186    pub format: OutputFormat,
187    pub format_explicit: bool,
188    pub mode: RenderMode,
189    pub color: ColorMode,
190    pub unicode: UnicodeMode,
191    pub theme_name: String,
192    pub(crate) theme: Option<ThemeDefinition>,
193    pub width: Option<usize>,
194    pub margin: usize,
195    pub indent_size: usize,
196    pub short_list_max: usize,
197    pub medium_list_max: usize,
198    pub grid_padding: usize,
199    pub grid_columns: Option<usize>,
200    pub column_weight: usize,
201    pub table_overflow: TableOverflow,
202    pub table_border: TableBorderStyle,
203    pub style_overrides: style::StyleOverrides,
204    pub help_chrome: HelpChromeSettings,
205    pub mreg_stack_min_col_width: usize,
206    pub mreg_stack_overflow_ratio: usize,
207    pub chrome_frame: SectionFrameStyle,
208    pub ruled_section_policy: RuledSectionPolicy,
209    pub guide_default_format: GuideDefaultFormat,
210    pub runtime: RenderRuntime,
211}
212
213impl Default for RenderSettings {
214    fn default() -> Self {
215        Self {
216            format: OutputFormat::Auto,
217            format_explicit: false,
218            mode: RenderMode::Auto,
219            color: ColorMode::Auto,
220            unicode: UnicodeMode::Auto,
221            theme_name: DEFAULT_THEME_NAME.to_string(),
222            theme: None,
223            width: None,
224            margin: 0,
225            indent_size: 2,
226            short_list_max: 1,
227            medium_list_max: 5,
228            grid_padding: 4,
229            grid_columns: None,
230            column_weight: 3,
231            table_overflow: TableOverflow::Clip,
232            table_border: TableBorderStyle::Square,
233            style_overrides: style::StyleOverrides::default(),
234            help_chrome: HelpChromeSettings::default(),
235            mreg_stack_min_col_width: 10,
236            mreg_stack_overflow_ratio: 200,
237            chrome_frame: SectionFrameStyle::Top,
238            ruled_section_policy: RuledSectionPolicy::Shared,
239            guide_default_format: GuideDefaultFormat::Guide,
240            runtime: RenderRuntime::default(),
241        }
242    }
243}
244
245impl RenderSettings {
246    pub fn builder() -> RenderSettingsBuilder {
247        RenderSettingsBuilder::default()
248    }
249
250    pub fn test_plain(format: OutputFormat) -> Self {
251        RenderSettingsBuilder::plain(format).build()
252    }
253
254    pub fn prefers_guide_rendering(&self) -> bool {
255        matches!(self.format, OutputFormat::Guide)
256            || (!self.format_explicit
257                && matches!(self.guide_default_format, GuideDefaultFormat::Guide))
258    }
259}
260
261impl TableOverflow {
262    pub fn parse(value: &str) -> Option<Self> {
263        match value.trim().to_ascii_lowercase().as_str() {
264            "none" | "visible" => Some(Self::None),
265            "clip" | "hidden" | "crop" => Some(Self::Clip),
266            "ellipsis" | "truncate" => Some(Self::Ellipsis),
267            "wrap" | "wrapped" => Some(Self::Wrap),
268            _ => None,
269        }
270    }
271}
272
273impl TableBorderStyle {
274    pub fn parse(value: &str) -> Option<Self> {
275        match value.trim().to_ascii_lowercase().as_str() {
276            "none" | "plain" => Some(Self::None),
277            "square" | "box" | "boxed" => Some(Self::Square),
278            "round" | "rounded" => Some(Self::Round),
279            _ => None,
280        }
281    }
282}
283
284pub fn help_layout_from_config(config: &ResolvedConfig) -> HelpLayout {
285    help_layout_from_presentation_name(config.get_string("ui.presentation"))
286}
287
288pub(crate) fn resolve_ui_presentation(config: &ResolvedConfig) -> UiPresentation {
289    config
290        .get_string("ui.presentation")
291        .and_then(UiPresentation::parse)
292        .unwrap_or(UiPresentation::Expressive)
293}
294
295pub(crate) fn build_presentation_defaults_layer(
296    config: &ResolvedConfig,
297) -> crate::config::ConfigLayer {
298    let mut layer = crate::config::ConfigLayer::default();
299    let presentation = resolve_ui_presentation(config);
300    for key in PRESENTATION_KEYS {
301        if config
302            .get_value_entry(key)
303            .map(|entry| matches!(entry.source, ConfigSource::BuiltinDefaults))
304            .unwrap_or(true)
305            && let Some(value) = presentation_seeded_value(presentation, key)
306        {
307            layer.set(*key, value);
308        }
309    }
310    layer
311}
312
313pub(crate) fn explain_presentation_effect(
314    config: &ResolvedConfig,
315    key: &str,
316) -> Option<PresentationEffect> {
317    let seeded_entry = config.get_value_entry(key)?;
318    if !matches!(seeded_entry.source, ConfigSource::PresentationDefaults) {
319        return None;
320    }
321
322    let preset_entry = config.get_value_entry("ui.presentation")?;
323    let preset = config
324        .get_string("ui.presentation")
325        .and_then(UiPresentation::parse)?;
326    let seeded_value = presentation_seeded_value(preset, key)?;
327
328    Some(PresentationEffect {
329        preset,
330        preset_source: preset_entry.source,
331        preset_scope: preset_entry.scope.clone(),
332        preset_origin: preset_entry.origin.clone(),
333        seeded_value,
334    })
335}
336
337pub(crate) fn apply_render_config_overrides(
338    settings: &mut RenderSettings,
339    config: &ResolvedConfig,
340) {
341    if let Some(value) = config.get_string("ui.format")
342        && let Some(parsed) = OutputFormat::parse(value)
343    {
344        settings.format = parsed;
345    }
346
347    if let Some(value) = config.get_string("ui.mode")
348        && let Some(parsed) = RenderMode::parse(value)
349    {
350        settings.mode = parsed;
351    }
352
353    if let Some(value) = config.get_string("ui.unicode.mode")
354        && let Some(parsed) = UnicodeMode::parse(value)
355    {
356        settings.unicode = parsed;
357    }
358
359    if let Some(value) = config.get_string("ui.color.mode")
360        && let Some(parsed) = ColorMode::parse(value)
361    {
362        settings.color = parsed;
363    }
364
365    if let Some(value) = config.get_string("ui.chrome.frame")
366        && let Some(parsed) = SectionFrameStyle::parse(value)
367    {
368        settings.chrome_frame = parsed;
369    }
370
371    if let Some(value) = config.get_string("ui.chrome.rule_policy")
372        && let Some(parsed) = RuledSectionPolicy::parse(value)
373    {
374        settings.ruled_section_policy = parsed;
375    }
376
377    if let Some(value) = config.get_string("ui.guide.default_format")
378        && let Some(parsed) = GuideDefaultFormat::parse(value)
379    {
380        settings.guide_default_format = parsed;
381    }
382
383    if settings.width.is_none() {
384        match config.get("ui.width").map(ConfigValue::reveal) {
385            Some(ConfigValue::Integer(width)) if *width > 0 => {
386                settings.width = Some(*width as usize);
387            }
388            Some(ConfigValue::String(raw)) => {
389                if let Ok(width) = raw.trim().parse::<usize>()
390                    && width > 0
391                {
392                    settings.width = Some(width);
393                }
394            }
395            _ => {}
396        }
397    }
398
399    sync_render_config_overrides(settings, config);
400}
401
402fn help_layout_from_presentation_name(value: Option<&str>) -> HelpLayout {
403    match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
404        Some("compact") => HelpLayout::Compact,
405        Some("austere") | Some("gammel-og-bitter") => HelpLayout::Minimal,
406        _ => HelpLayout::Full,
407    }
408}
409
410const PRESENTATION_KEYS: &[&str] = &[
411    "ui.mode",
412    "ui.unicode.mode",
413    "ui.color.mode",
414    "ui.chrome.frame",
415    "ui.table.border",
416    "ui.messages.layout",
417    "repl.simple_prompt",
418    "repl.intro",
419];
420
421fn presentation_seeded_value(presentation: UiPresentation, key: &str) -> Option<ConfigValue> {
422    match key {
423        "ui.mode" => match presentation {
424            UiPresentation::Austere => Some(ConfigValue::from("plain")),
425            UiPresentation::Compact | UiPresentation::Expressive => None,
426        },
427        "ui.unicode.mode" => match presentation {
428            UiPresentation::Compact | UiPresentation::Austere => Some(ConfigValue::from("never")),
429            UiPresentation::Expressive => None,
430        },
431        "ui.color.mode" => match presentation {
432            UiPresentation::Austere => Some(ConfigValue::from("never")),
433            UiPresentation::Compact | UiPresentation::Expressive => None,
434        },
435        "ui.chrome.frame" => match presentation {
436            UiPresentation::Expressive => Some(ConfigValue::from("top-bottom")),
437            UiPresentation::Compact => Some(ConfigValue::from("top")),
438            UiPresentation::Austere => Some(ConfigValue::from("none")),
439        },
440        "ui.table.border" => match presentation {
441            UiPresentation::Expressive => Some(ConfigValue::from("round")),
442            UiPresentation::Compact | UiPresentation::Austere => Some(ConfigValue::from("square")),
443        },
444        "ui.messages.layout" => match presentation {
445            UiPresentation::Austere => Some(ConfigValue::from("austere")),
446            UiPresentation::Compact => Some(ConfigValue::from("compact")),
447            UiPresentation::Expressive => Some(ConfigValue::from("full")),
448        },
449        "repl.simple_prompt" => match presentation {
450            UiPresentation::Expressive => Some(ConfigValue::Bool(false)),
451            UiPresentation::Compact | UiPresentation::Austere => Some(ConfigValue::Bool(true)),
452        },
453        "repl.intro" => match presentation {
454            UiPresentation::Austere => Some(ConfigValue::from("minimal")),
455            UiPresentation::Compact => Some(ConfigValue::from("compact")),
456            UiPresentation::Expressive => Some(ConfigValue::from("full")),
457        },
458        _ => None,
459    }
460}
461
462fn sync_render_config_overrides(settings: &mut RenderSettings, config: &ResolvedConfig) {
463    if let Some(value) = config_int(config, "ui.margin")
464        && value >= 0
465    {
466        settings.margin = value as usize;
467    }
468
469    if let Some(value) = config_int(config, "ui.indent")
470        && value > 0
471    {
472        settings.indent_size = value as usize;
473    }
474
475    if let Some(value) = config_int(config, "ui.short_list_max")
476        && value > 0
477    {
478        settings.short_list_max = value as usize;
479    }
480
481    if let Some(value) = config_int(config, "ui.medium_list_max")
482        && value > 0
483    {
484        settings.medium_list_max = value as usize;
485    }
486
487    if let Some(value) = config_int(config, "ui.grid_padding")
488        && value > 0
489    {
490        settings.grid_padding = value as usize;
491    }
492
493    if let Some(value) = config_int(config, "ui.grid_columns") {
494        settings.grid_columns = if value > 0 {
495            Some(value as usize)
496        } else {
497            None
498        };
499    }
500
501    if let Some(value) = config_int(config, "ui.column_weight")
502        && value > 0
503    {
504        settings.column_weight = value as usize;
505    }
506
507    if let Some(value) = config_int(config, "ui.mreg.stack_min_col_width")
508        && value > 0
509    {
510        settings.mreg_stack_min_col_width = value as usize;
511    }
512
513    if let Some(value) = config_int(config, "ui.mreg.stack_overflow_ratio")
514        && value >= 100
515    {
516        settings.mreg_stack_overflow_ratio = value as usize;
517    }
518
519    if let Some(value) = config.get_string("ui.table.overflow")
520        && let Some(parsed) = TableOverflow::parse(value)
521    {
522        settings.table_overflow = parsed;
523    }
524
525    if let Some(value) = config.get_string("ui.table.border")
526        && let Some(parsed) = TableBorderStyle::parse(value)
527    {
528        settings.table_border = parsed;
529    }
530
531    if let Some(value) = config.get_string("ui.help.table_chrome")
532        && let Some(parsed) = HelpTableChrome::parse(value)
533    {
534        settings.help_chrome.table_chrome = parsed;
535    }
536
537    settings.help_chrome.entry_indent = config_usize_override(config, "ui.help.entry_indent");
538    settings.help_chrome.entry_gap = config_usize_override(config, "ui.help.entry_gap");
539    settings.help_chrome.section_spacing = config_usize_override(config, "ui.help.section_spacing");
540
541    settings.style_overrides = style::StyleOverrides {
542        text: config_non_empty_string(config, "color.text"),
543        key: config_non_empty_string(config, "color.key"),
544        muted: config_non_empty_string(config, "color.text.muted"),
545        table_header: config_non_empty_string(config, "color.table.header"),
546        mreg_key: config_non_empty_string(config, "color.mreg.key"),
547        value: config_non_empty_string(config, "color.value"),
548        number: config_non_empty_string(config, "color.value.number"),
549        bool_true: config_non_empty_string(config, "color.value.bool_true"),
550        bool_false: config_non_empty_string(config, "color.value.bool_false"),
551        null_value: config_non_empty_string(config, "color.value.null"),
552        ipv4: config_non_empty_string(config, "color.value.ipv4"),
553        ipv6: config_non_empty_string(config, "color.value.ipv6"),
554        panel_border: config_non_empty_string(config, "color.panel.border")
555            .or_else(|| config_non_empty_string(config, "color.border")),
556        panel_title: config_non_empty_string(config, "color.panel.title"),
557        code: config_non_empty_string(config, "color.code"),
558        json_key: config_non_empty_string(config, "color.json.key"),
559        message_error: config_non_empty_string(config, "color.message.error"),
560        message_warning: config_non_empty_string(config, "color.message.warning"),
561        message_success: config_non_empty_string(config, "color.message.success"),
562        message_info: config_non_empty_string(config, "color.message.info"),
563        message_trace: config_non_empty_string(config, "color.message.trace"),
564    };
565}
566
567fn config_int(config: &ResolvedConfig, key: &str) -> Option<i64> {
568    match config.get(key).map(ConfigValue::reveal) {
569        Some(ConfigValue::Integer(value)) => Some(*value),
570        Some(ConfigValue::String(raw)) => raw.trim().parse::<i64>().ok(),
571        _ => None,
572    }
573}
574
575fn config_non_empty_string(config: &ResolvedConfig, key: &str) -> Option<String> {
576    config
577        .get_string(key)
578        .map(str::trim)
579        .filter(|value| !value.is_empty())
580        .map(ToOwned::to_owned)
581}
582
583fn config_usize_override(config: &ResolvedConfig, key: &str) -> Option<usize> {
584    match config.get(key).map(ConfigValue::reveal) {
585        Some(ConfigValue::Integer(value)) if *value >= 0 => Some(*value as usize),
586        Some(ConfigValue::String(raw)) => {
587            let trimmed = raw.trim();
588            if trimmed.eq_ignore_ascii_case("inherit") || trimmed.is_empty() {
589                None
590            } else {
591                trimmed.parse::<usize>().ok()
592            }
593        }
594        _ => None,
595    }
596}
597
598#[derive(Debug, Clone, Default)]
599pub struct RenderSettingsBuilder {
600    settings: RenderSettings,
601}
602
603impl RenderSettingsBuilder {
604    pub fn plain(format: OutputFormat) -> Self {
605        Self {
606            settings: RenderSettings {
607                format,
608                format_explicit: false,
609                mode: RenderMode::Plain,
610                color: ColorMode::Never,
611                unicode: UnicodeMode::Never,
612                ..RenderSettings::default()
613            },
614        }
615    }
616
617    pub fn with_format(mut self, format: OutputFormat) -> Self {
618        self.settings.format = format;
619        self
620    }
621
622    pub fn with_format_explicit(mut self, format_explicit: bool) -> Self {
623        self.settings.format_explicit = format_explicit;
624        self
625    }
626
627    pub fn with_mode(mut self, mode: RenderMode) -> Self {
628        self.settings.mode = mode;
629        self
630    }
631
632    pub fn with_color(mut self, color: ColorMode) -> Self {
633        self.settings.color = color;
634        self
635    }
636
637    pub fn with_unicode(mut self, unicode: UnicodeMode) -> Self {
638        self.settings.unicode = unicode;
639        self
640    }
641
642    pub fn with_width(mut self, width: usize) -> Self {
643        self.settings.width = Some(width);
644        self
645    }
646
647    pub fn with_margin(mut self, margin: usize) -> Self {
648        self.settings.margin = margin;
649        self
650    }
651
652    pub fn with_indent_size(mut self, indent_size: usize) -> Self {
653        self.settings.indent_size = indent_size;
654        self
655    }
656
657    pub fn with_table_overflow(mut self, table_overflow: TableOverflow) -> Self {
658        self.settings.table_overflow = table_overflow;
659        self
660    }
661
662    pub fn with_table_border(mut self, table_border: TableBorderStyle) -> Self {
663        self.settings.table_border = table_border;
664        self
665    }
666
667    pub fn with_help_chrome(mut self, help_chrome: HelpChromeSettings) -> Self {
668        self.settings.help_chrome = help_chrome;
669        self
670    }
671
672    pub fn with_theme_name(mut self, theme_name: impl Into<String>) -> Self {
673        self.settings.theme_name = theme_name.into();
674        self
675    }
676
677    pub fn with_style_overrides(mut self, style_overrides: style::StyleOverrides) -> Self {
678        self.settings.style_overrides = style_overrides;
679        self
680    }
681
682    pub fn with_chrome_frame(mut self, chrome_frame: SectionFrameStyle) -> Self {
683        self.settings.chrome_frame = chrome_frame;
684        self
685    }
686
687    pub fn with_ruled_section_policy(mut self, ruled_section_policy: RuledSectionPolicy) -> Self {
688        self.settings.ruled_section_policy = ruled_section_policy;
689        self
690    }
691
692    pub fn with_guide_default_format(mut self, guide_default_format: GuideDefaultFormat) -> Self {
693        self.settings.guide_default_format = guide_default_format;
694        self
695    }
696
697    pub fn with_runtime(mut self, runtime: RenderRuntime) -> Self {
698        self.settings.runtime = runtime;
699        self
700    }
701
702    pub fn build(self) -> RenderSettings {
703        self.settings
704    }
705}
706
707#[derive(Debug, Clone, Copy, PartialEq, Eq)]
708pub enum RenderProfile {
709    Normal,
710    CopySafe,
711}
712
713#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714pub struct ResolvedHelpChromeSettings {
715    pub entry_indent: usize,
716    pub entry_gap: Option<usize>,
717    pub section_spacing: usize,
718}
719
720#[derive(Debug, Clone, PartialEq, Eq)]
721pub struct ResolvedRenderSettings {
722    pub backend: RenderBackend,
723    pub color: bool,
724    pub unicode: bool,
725    pub width: Option<usize>,
726    pub margin: usize,
727    pub indent_size: usize,
728    pub short_list_max: usize,
729    pub medium_list_max: usize,
730    pub grid_padding: usize,
731    pub grid_columns: Option<usize>,
732    pub column_weight: usize,
733    pub table_overflow: TableOverflow,
734    pub table_border: TableBorderStyle,
735    pub help_table_border: TableBorderStyle,
736    pub theme_name: String,
737    pub theme: ThemeDefinition,
738    pub style_overrides: style::StyleOverrides,
739    pub help_chrome: ResolvedHelpChromeSettings,
740    pub chrome_frame: SectionFrameStyle,
741    pub guide_default_format: GuideDefaultFormat,
742}
743
744impl RenderSettings {
745    fn resolve_color_mode(&self) -> bool {
746        match self.color {
747            ColorMode::Always => true,
748            ColorMode::Never => false,
749            ColorMode::Auto => !self.runtime.no_color && self.runtime.stdout_is_tty,
750        }
751    }
752
753    fn resolve_unicode_mode(&self) -> bool {
754        match self.unicode {
755            UnicodeMode::Always => true,
756            UnicodeMode::Never => false,
757            UnicodeMode::Auto => {
758                if !self.runtime.stdout_is_tty {
759                    return false;
760                }
761                if matches!(self.runtime.terminal.as_deref(), Some("dumb")) {
762                    return false;
763                }
764                match self.runtime.locale_utf8 {
765                    Some(true) => true,
766                    Some(false) => false,
767                    None => true,
768                }
769            }
770        }
771    }
772
773    fn resolve_width(&self) -> Option<usize> {
774        if let Some(width) = self.width {
775            return (width > 0).then_some(width);
776        }
777        self.runtime.width.filter(|width| *width > 0)
778    }
779
780    pub fn resolve_render_settings(&self) -> ResolvedRenderSettings {
781        let backend = match self.mode {
782            RenderMode::Plain => RenderBackend::Plain,
783            RenderMode::Rich => RenderBackend::Rich,
784            RenderMode::Auto => {
785                if matches!(self.color, ColorMode::Always)
786                    || matches!(self.unicode, UnicodeMode::Always)
787                {
788                    RenderBackend::Rich
789                } else if !self.runtime.stdout_is_tty
790                    || matches!(self.runtime.terminal.as_deref(), Some("dumb"))
791                {
792                    RenderBackend::Plain
793                } else {
794                    RenderBackend::Rich
795                }
796            }
797        };
798
799        let theme = self
800            .theme
801            .clone()
802            .unwrap_or_else(|| theme::resolve_theme(&self.theme_name));
803        let theme_name = theme::normalize_theme_name(&theme.id);
804        let help_chrome = ResolvedHelpChromeSettings {
805            entry_indent: self.help_chrome.entry_indent.unwrap_or(2),
806            entry_gap: self.help_chrome.entry_gap,
807            section_spacing: self.help_chrome.section_spacing.unwrap_or(1),
808        };
809
810        match backend {
811            RenderBackend::Plain => ResolvedRenderSettings {
812                backend,
813                color: false,
814                unicode: false,
815                width: self.resolve_width(),
816                margin: self.margin,
817                indent_size: self.indent_size.max(1),
818                short_list_max: self.short_list_max.max(1),
819                medium_list_max: self.medium_list_max.max(self.short_list_max.max(1) + 1),
820                grid_padding: self.grid_padding.max(1),
821                grid_columns: self.grid_columns.filter(|value| *value > 0),
822                column_weight: self.column_weight.max(1),
823                table_overflow: self.table_overflow,
824                table_border: self.table_border,
825                help_table_border: self.help_chrome.table_chrome.resolve(self.table_border),
826                theme_name,
827                theme: theme.clone(),
828                style_overrides: self.style_overrides.clone(),
829                help_chrome,
830                chrome_frame: self.chrome_frame,
831                guide_default_format: self.guide_default_format,
832            },
833            RenderBackend::Rich => ResolvedRenderSettings {
834                backend,
835                color: self.resolve_color_mode(),
836                unicode: self.resolve_unicode_mode(),
837                width: self.resolve_width(),
838                margin: self.margin,
839                indent_size: self.indent_size.max(1),
840                short_list_max: self.short_list_max.max(1),
841                medium_list_max: self.medium_list_max.max(self.short_list_max.max(1) + 1),
842                grid_padding: self.grid_padding.max(1),
843                grid_columns: self.grid_columns.filter(|value| *value > 0),
844                column_weight: self.column_weight.max(1),
845                table_overflow: self.table_overflow,
846                table_border: self.table_border,
847                help_table_border: self.help_chrome.table_chrome.resolve(self.table_border),
848                theme_name,
849                theme,
850                style_overrides: self.style_overrides.clone(),
851                help_chrome,
852                chrome_frame: self.chrome_frame,
853                guide_default_format: self.guide_default_format,
854            },
855        }
856    }
857
858    pub(crate) fn plain_copy_settings(&self) -> Self {
859        Self {
860            format: self.format,
861            format_explicit: self.format_explicit,
862            mode: RenderMode::Plain,
863            color: ColorMode::Never,
864            unicode: UnicodeMode::Never,
865            width: self.width,
866            margin: self.margin,
867            indent_size: self.indent_size,
868            short_list_max: self.short_list_max,
869            medium_list_max: self.medium_list_max,
870            grid_padding: self.grid_padding,
871            grid_columns: self.grid_columns,
872            column_weight: self.column_weight,
873            table_overflow: self.table_overflow,
874            table_border: self.table_border,
875            help_chrome: self.help_chrome,
876            mreg_stack_min_col_width: self.mreg_stack_min_col_width,
877            mreg_stack_overflow_ratio: self.mreg_stack_overflow_ratio,
878            theme_name: self.theme_name.clone(),
879            theme: self.theme.clone(),
880            style_overrides: self.style_overrides.clone(),
881            chrome_frame: self.chrome_frame,
882            ruled_section_policy: self.ruled_section_policy,
883            guide_default_format: self.guide_default_format,
884            runtime: self.runtime.clone(),
885        }
886    }
887
888    pub(crate) fn resolve_output_format(&self, output: &OutputResult) -> OutputFormat {
889        if self.format_explicit && !matches!(self.format, OutputFormat::Auto) {
890            return self.format;
891        }
892
893        if crate::guide::GuideView::try_from_output_result(output).is_some()
894            && self.prefers_guide_rendering()
895        {
896            return OutputFormat::Guide;
897        }
898
899        if let Some(recommended) = output.meta.render_recommendation {
900            return match recommended {
901                RenderRecommendation::Format(format) => format,
902                RenderRecommendation::Guide => OutputFormat::Guide,
903            };
904        }
905
906        if !matches!(self.format, OutputFormat::Auto) {
907            return self.format;
908        }
909
910        if matches!(output.items, OutputItems::Groups(_)) {
911            return OutputFormat::Table;
912        }
913
914        let rows = output_items_to_rows(&output.items);
915        if rows
916            .iter()
917            .all(|row| row.len() == 1 && row.contains_key("value"))
918        {
919            OutputFormat::Value
920        } else if rows.len() <= 1 {
921            OutputFormat::Mreg
922        } else {
923            OutputFormat::Table
924        }
925    }
926}
927
928pub fn resolve_settings(
929    settings: &RenderSettings,
930    profile: RenderProfile,
931) -> ResolvedRenderSettings {
932    if matches!(profile, RenderProfile::CopySafe) {
933        settings.plain_copy_settings().resolve_render_settings()
934    } else {
935        settings.resolve_render_settings()
936    }
937}
938
939#[cfg(test)]
940mod tests {
941    use super::{
942        GuideDefaultFormat, HelpChromeSettings, HelpLayout, HelpTableChrome, RenderBackend,
943        RenderProfile, RenderRuntime, RenderSettingsBuilder, TableBorderStyle, TableOverflow,
944        UiPresentation, apply_render_config_overrides, config_int, config_non_empty_string,
945        config_usize_override, explain_presentation_effect, help_layout_from_config,
946    };
947    use crate::config::{
948        ConfigLayer, ConfigResolver, ConfigSource, ConfigValue, LoadedLayers, ResolveOptions,
949    };
950    use crate::core::output::{ColorMode, OutputFormat, RenderMode, UnicodeMode};
951    use crate::core::output_model::{
952        Group, OutputItems, OutputMeta, OutputResult, RenderRecommendation,
953    };
954    use crate::guide::GuideView;
955    use crate::row;
956    use crate::ui::build_presentation_defaults_layer;
957    use crate::ui::section_chrome::{RuledSectionPolicy, SectionFrameStyle};
958    use crate::ui::settings::RenderSettings;
959
960    fn resolved_config(entries: &[(&str, &str)]) -> crate::config::ResolvedConfig {
961        let mut defaults = ConfigLayer::default();
962        for (key, value) in entries {
963            defaults.set(*key, *value);
964        }
965        ConfigResolver::from_loaded_layers(LoadedLayers {
966            defaults,
967            ..LoadedLayers::default()
968        })
969        .resolve(ResolveOptions::default())
970        .expect("config should resolve")
971    }
972
973    fn resolved_config_with_presentation(
974        entries: &[(&str, &str)],
975    ) -> crate::config::ResolvedConfig {
976        let mut defaults = ConfigLayer::default();
977        defaults.set("profile.default", "default");
978        for (key, value) in entries {
979            defaults.set(*key, *value);
980        }
981        let mut resolver = ConfigResolver::default();
982        resolver.set_defaults(defaults);
983        let options = ResolveOptions::default().with_terminal("cli");
984        let base = resolver
985            .resolve(options.clone())
986            .expect("base test config should resolve");
987        resolver.set_presentation(build_presentation_defaults_layer(&base));
988        resolver
989            .resolve(options)
990            .expect("test config should resolve")
991    }
992
993    fn resolved_config_with_session(
994        defaults_entries: &[(&str, &str)],
995        session_entries: &[(&str, &str)],
996    ) -> crate::config::ResolvedConfig {
997        let mut defaults = ConfigLayer::default();
998        defaults.set("profile.default", "default");
999        for (key, value) in defaults_entries {
1000            defaults.set(*key, *value);
1001        }
1002
1003        let mut resolver = ConfigResolver::default();
1004        resolver.set_defaults(defaults);
1005
1006        let mut session = ConfigLayer::default();
1007        for (key, value) in session_entries {
1008            session.set(*key, *value);
1009        }
1010        resolver.set_session(session);
1011
1012        let options = ResolveOptions::default().with_terminal("cli");
1013        let base = resolver
1014            .resolve(options.clone())
1015            .expect("base test config should resolve");
1016        resolver.set_presentation(build_presentation_defaults_layer(&base));
1017        resolver
1018            .resolve(options)
1019            .expect("test config should resolve")
1020    }
1021
1022    #[test]
1023    fn help_layout_from_config_owns_presentation_mapping_unit() {
1024        assert_eq!(
1025            help_layout_from_config(&resolved_config(&[])),
1026            HelpLayout::Full
1027        );
1028        assert_eq!(
1029            help_layout_from_config(&resolved_config(&[("ui.presentation", "expressive")])),
1030            HelpLayout::Full
1031        );
1032        assert_eq!(
1033            help_layout_from_config(&resolved_config(&[("ui.presentation", "compact")])),
1034            HelpLayout::Compact
1035        );
1036        assert_eq!(
1037            help_layout_from_config(&resolved_config(&[("ui.presentation", "austere")])),
1038            HelpLayout::Minimal
1039        );
1040        assert_eq!(
1041            help_layout_from_config(&resolved_config(&[("ui.presentation", "gammel-og-bitter")])),
1042            HelpLayout::Minimal
1043        );
1044    }
1045
1046    #[test]
1047    fn render_config_helpers_normalize_strings_blanks_and_integers_unit() {
1048        let config = resolved_config_with_presentation(&[
1049            ("ui.width", "120"),
1050            ("color.text", "  "),
1051            ("ui.margin", "3"),
1052        ]);
1053
1054        assert_eq!(config_int(&config, "ui.width"), Some(120));
1055        assert_eq!(config_int(&config, "ui.margin"), Some(3));
1056        assert_eq!(config_non_empty_string(&config, "color.text"), None);
1057    }
1058
1059    #[test]
1060    fn render_config_overrides_use_presentation_defaults_and_explicit_overrides_unit() {
1061        let config = resolved_config_with_session(
1062            &[("ui.width", "88")],
1063            &[
1064                ("ui.chrome.frame", "round"),
1065                ("ui.chrome.rule_policy", "stacked"),
1066                ("ui.table.border", "square"),
1067                ("ui.table.overflow", "wrap"),
1068            ],
1069        );
1070        let mut settings = RenderSettings::test_plain(OutputFormat::Table);
1071
1072        apply_render_config_overrides(&mut settings, &config);
1073
1074        assert_eq!(settings.width, Some(88));
1075        assert_eq!(settings.chrome_frame, SectionFrameStyle::Round);
1076        assert_eq!(settings.ruled_section_policy, RuledSectionPolicy::Shared);
1077        assert_eq!(settings.table_border, TableBorderStyle::Square);
1078        assert_eq!(settings.table_overflow, TableOverflow::Wrap);
1079
1080        let config = resolved_config_with_presentation(&[("ui.presentation", "expressive")]);
1081        let mut settings = RenderSettings::test_plain(OutputFormat::Table);
1082
1083        apply_render_config_overrides(&mut settings, &config);
1084
1085        assert_eq!(settings.chrome_frame, SectionFrameStyle::TopBottom);
1086        assert_eq!(settings.table_border, TableBorderStyle::Round);
1087
1088        let config = resolved_config_with_session(
1089            &[("ui.presentation", "expressive")],
1090            &[("ui.chrome.frame", "square"), ("ui.table.border", "none")],
1091        );
1092        let mut settings = RenderSettings::test_plain(OutputFormat::Table);
1093
1094        apply_render_config_overrides(&mut settings, &config);
1095
1096        assert_eq!(settings.chrome_frame, SectionFrameStyle::Square);
1097        assert_eq!(settings.table_border, TableBorderStyle::None);
1098
1099        let config = resolved_config_with_presentation(&[("ui.guide.default_format", "inherit")]);
1100        let mut settings = RenderSettings::test_plain(OutputFormat::Json);
1101
1102        apply_render_config_overrides(&mut settings, &config);
1103
1104        assert_eq!(settings.guide_default_format, GuideDefaultFormat::Inherit);
1105
1106        let config = resolved_config_with_presentation(&[
1107            ("ui.help.entry_indent", "4"),
1108            ("ui.help.entry_gap", "3"),
1109            ("ui.help.section_spacing", "inherit"),
1110        ]);
1111        let mut settings = RenderSettings::test_plain(OutputFormat::Guide);
1112
1113        apply_render_config_overrides(&mut settings, &config);
1114
1115        assert_eq!(
1116            config_usize_override(&config, "ui.help.entry_indent"),
1117            Some(4)
1118        );
1119        assert_eq!(settings.help_chrome.entry_indent, Some(4));
1120        assert_eq!(settings.help_chrome.entry_gap, Some(3));
1121        assert_eq!(settings.help_chrome.section_spacing, None);
1122    }
1123
1124    #[test]
1125    fn render_settings_resolution_and_output_format_ownership_unit() {
1126        let defaults = RenderSettings::default();
1127        assert_eq!(defaults.format, OutputFormat::Auto);
1128        assert_eq!(defaults.theme_name, super::DEFAULT_THEME_NAME);
1129        assert_eq!(defaults.table_border, TableBorderStyle::Square);
1130        assert_eq!(defaults.help_chrome, HelpChromeSettings::default());
1131
1132        let plain_settings = RenderSettingsBuilder::default()
1133            .with_runtime(
1134                RenderRuntime::builder()
1135                    .with_stdout_is_tty(false)
1136                    .with_width(72)
1137                    .build(),
1138            )
1139            .build();
1140        let plain = plain_settings.resolve_render_settings();
1141        assert_eq!(plain.backend, RenderBackend::Plain);
1142        assert!(!plain.color);
1143        assert!(!plain.unicode);
1144        assert_eq!(plain.width, Some(72));
1145
1146        let rich_settings = RenderSettingsBuilder::default()
1147            .with_mode(RenderMode::Auto)
1148            .with_color(ColorMode::Always)
1149            .with_unicode(UnicodeMode::Always)
1150            .with_runtime(
1151                RenderRuntime::builder()
1152                    .with_stdout_is_tty(true)
1153                    .with_terminal("xterm-256color")
1154                    .with_locale_utf8(true)
1155                    .with_width(88)
1156                    .build(),
1157            )
1158            .build();
1159        let rich = rich_settings.resolve_render_settings();
1160        assert_eq!(rich.backend, RenderBackend::Rich);
1161        assert!(rich.color);
1162        assert!(rich.unicode);
1163        assert_eq!(rich.width, Some(88));
1164
1165        let copy = rich_settings.plain_copy_settings();
1166        let resolved_copy = super::resolve_settings(&rich_settings, RenderProfile::CopySafe);
1167        assert_eq!(copy.mode, RenderMode::Plain);
1168        assert_eq!(copy.color, ColorMode::Never);
1169        assert_eq!(copy.unicode, UnicodeMode::Never);
1170        assert_eq!(resolved_copy.backend, RenderBackend::Plain);
1171        assert!(!resolved_copy.color);
1172        assert!(!resolved_copy.unicode);
1173
1174        let guide_output =
1175            GuideView::from_text("Usage: osp history <COMMAND>\n").to_output_result();
1176        assert_eq!(
1177            plain_settings.resolve_output_format(&guide_output),
1178            OutputFormat::Guide
1179        );
1180
1181        let mut explicit_json = RenderSettings::test_plain(OutputFormat::Json);
1182        explicit_json.format_explicit = true;
1183        assert_eq!(
1184            explicit_json.resolve_output_format(&guide_output),
1185            OutputFormat::Json
1186        );
1187
1188        let mut recommended = OutputResult::from_rows(vec![row! { "uid" => "alice" }]);
1189        recommended.meta.render_recommendation =
1190            Some(RenderRecommendation::Format(OutputFormat::Markdown));
1191        assert_eq!(
1192            plain_settings.resolve_output_format(&recommended),
1193            OutputFormat::Markdown
1194        );
1195
1196        let grouped = OutputResult {
1197            items: OutputItems::Groups(vec![Group {
1198                groups: row! { "team" => "prod" },
1199                aggregates: row! { "count" => 2 },
1200                rows: vec![row! { "uid" => "alice" }, row! { "uid" => "bob" }],
1201            }]),
1202            document: None,
1203            meta: OutputMeta {
1204                key_index: vec!["team".to_string(), "count".to_string(), "uid".to_string()],
1205                column_align: Vec::new(),
1206                wants_copy: false,
1207                grouped: true,
1208                render_recommendation: None,
1209            },
1210        };
1211        assert_eq!(
1212            plain_settings.resolve_output_format(&grouped),
1213            OutputFormat::Table
1214        );
1215
1216        let value_output = OutputResult::from_rows(vec![row! { "value" => "hello" }]);
1217        assert_eq!(
1218            plain_settings.resolve_output_format(&value_output),
1219            OutputFormat::Value
1220        );
1221
1222        let mreg_output = OutputResult::from_rows(vec![row! { "uid" => "alice" }]);
1223        assert_eq!(
1224            plain_settings.resolve_output_format(&mreg_output),
1225            OutputFormat::Mreg
1226        );
1227    }
1228
1229    #[test]
1230    fn parser_aliases_and_presentation_effects_remain_canonical_unit() {
1231        assert_eq!(
1232            GuideDefaultFormat::parse("none"),
1233            Some(GuideDefaultFormat::Inherit)
1234        );
1235        assert_eq!(
1236            HelpTableChrome::parse("boxed"),
1237            Some(HelpTableChrome::Square)
1238        );
1239        assert_eq!(
1240            HelpTableChrome::parse("rounded"),
1241            Some(HelpTableChrome::Round)
1242        );
1243        assert_eq!(
1244            HelpTableChrome::Square.resolve(TableBorderStyle::None),
1245            TableBorderStyle::Square
1246        );
1247        assert_eq!(TableOverflow::parse("hidden"), Some(TableOverflow::Clip));
1248        assert_eq!(
1249            TableBorderStyle::parse("boxed"),
1250            Some(TableBorderStyle::Square)
1251        );
1252        assert_eq!(
1253            UiPresentation::parse("gammel-og-bitter"),
1254            Some(UiPresentation::Austere)
1255        );
1256        assert_eq!(UiPresentation::Compact.as_config_value(), "compact");
1257
1258        let config = resolved_config_with_presentation(&[("ui.presentation", "compact")]);
1259        let effect = explain_presentation_effect(&config, "ui.messages.layout")
1260            .expect("presentation default should be explainable");
1261        assert_eq!(effect.preset, UiPresentation::Compact);
1262        assert_eq!(effect.seeded_value, ConfigValue::from("compact"));
1263        assert_eq!(effect.preset_source, ConfigSource::BuiltinDefaults);
1264    }
1265}