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}