Skip to main content

osp_cli/ui/
mod.rs

1//! The UI module exists to turn structured output into predictable terminal
2//! text, while keeping rendering decisions separate from business logic.
3//!
4//! The UI stack has three layers:
5//!
6//! - [`format`] lowers rows and semantic outputs into a structured
7//!   [`crate::ui::Document`].
8//! - the internal renderer turns that document into terminal text using
9//!   resolved width, color, unicode, and theme settings.
10//! - inline/theme/style helpers provide smaller reusable building blocks for
11//!   prompts, messages, and rich text fragments.
12//!
13//! Keep the distinction between "document shaping" and "terminal rendering"
14//! clear. Most bugs become easier to localize once you know which side of that
15//! boundary is wrong.
16//!
17//! Contract:
18//!
19//! - UI code may depend on structured output and render settings
20//! - it should not own config precedence, command execution, or provider I/O
21//! - terminal styling decisions should stay here rather than leaking into the
22//!   rest of the app
23//!
24//! Public API shape:
25//!
26//! - semantic payloads like [`crate::ui::Document`] stay direct and cheap to
27//!   inspect
28//! - [`crate::ui::RenderRuntimeBuilder`] and
29//!   [`crate::ui::RenderSettingsBuilder`] are the guided construction path for
30//!   the heavier rendering configuration surfaces
31//! - [`crate::ui::ResolvedRenderSettings`] stays a derived value, not another
32//!   mutable configuration object
33//! - guided render configuration follows the crate-wide naming rule:
34//!   `builder(...)` returns `*Builder`, builder setters use `with_*`, and
35//!   `build()` is the terminal step
36//! - callers that only need a stable default baseline can use
37//!   [`crate::ui::RenderSettings::builder`],
38//!   [`crate::ui::RenderRuntime::builder`], or
39//!   [`crate::ui::RenderSettings::test_plain`]
40
41pub mod chrome;
42/// Clipboard integration helpers for copy-safe output flows.
43pub mod clipboard;
44mod display;
45pub mod document;
46pub(crate) mod document_model;
47pub(crate) mod format;
48/// Lightweight inline-markup parsing and rendering helpers.
49pub mod inline;
50pub mod interactive;
51mod layout;
52pub mod messages;
53pub(crate) mod presentation;
54mod renderer;
55mod resolution;
56/// Semantic style tokens and explicit style overrides layered over the theme.
57pub mod style;
58/// Built-in theme definitions and theme lookup helpers.
59pub mod theme;
60pub(crate) mod theme_loader;
61mod width;
62
63use crate::core::output::{ColorMode, OutputFormat, RenderMode, UnicodeMode};
64use crate::core::output_model::{OutputItems, OutputResult};
65use crate::core::row::Row;
66use crate::guide::GuideView;
67
68pub use chrome::{
69    RuledSectionPolicy, SectionFrameStyle, SectionRenderContext, SectionStyleTokens,
70    render_section_block_with_overrides, render_section_divider_with_overrides,
71};
72pub use clipboard::{ClipboardError, ClipboardService};
73pub use document::{
74    Block, CodeBlock, Document, JsonBlock, LineBlock, LinePart, MregBlock, MregEntry, MregRow,
75    MregValue, PanelBlock, PanelRules, TableAlign, TableBlock, TableStyle, ValueBlock,
76};
77pub use inline::{line_from_inline, parts_from_inline, render_inline};
78pub use interactive::{Interactive, InteractiveResult, InteractiveRuntime, Spinner};
79pub use messages::{
80    GroupedRenderOptions, MessageBuffer, MessageLayout, MessageLevel, UiMessage, adjust_verbosity,
81};
82pub(crate) use resolution::ResolvedGuideRenderSettings;
83#[cfg(test)]
84pub(crate) use resolution::ResolvedHelpChromeSettings;
85pub(crate) use resolution::ResolvedRenderPlan;
86pub use resolution::ResolvedRenderSettings;
87pub use style::{StyleOverrides, StyleToken};
88pub use theme::{
89    DEFAULT_THEME_NAME, ThemeDefinition, ThemeOverrides, ThemePalette, all_themes,
90    available_theme_names, builtin_themes, display_name_from_id, find_builtin_theme, find_theme,
91    is_known_theme, normalize_theme_name, resolve_theme,
92};
93
94/// Runtime terminal characteristics used when resolving render behavior.
95#[derive(Debug, Clone, Default, PartialEq, Eq)]
96#[non_exhaustive]
97pub struct RenderRuntime {
98    /// Whether standard output is attached to a TTY.
99    pub stdout_is_tty: bool,
100    /// Terminal program identifier when known.
101    pub terminal: Option<String>,
102    /// Whether color should be suppressed regardless of theme.
103    pub no_color: bool,
104    /// Measured terminal width, when available.
105    pub width: Option<usize>,
106    /// Whether the locale is known to support UTF-8.
107    pub locale_utf8: Option<bool>,
108}
109
110impl RenderRuntime {
111    /// Starts building runtime terminal facts for render resolution.
112    pub fn builder() -> RenderRuntimeBuilder {
113        RenderRuntimeBuilder::new()
114    }
115}
116
117/// Builder for [`RenderRuntime`].
118#[derive(Debug, Clone, Default)]
119pub struct RenderRuntimeBuilder {
120    runtime: RenderRuntime,
121}
122
123impl RenderRuntimeBuilder {
124    /// Creates a builder seeded with [`RenderRuntime::default`].
125    pub fn new() -> Self {
126        Self::default()
127    }
128
129    /// Sets whether standard output is attached to a TTY.
130    pub fn with_stdout_is_tty(mut self, stdout_is_tty: bool) -> Self {
131        self.runtime.stdout_is_tty = stdout_is_tty;
132        self
133    }
134
135    /// Sets the terminal program identifier.
136    pub fn with_terminal(mut self, terminal: impl Into<String>) -> Self {
137        self.runtime.terminal = Some(terminal.into());
138        self
139    }
140
141    /// Sets whether color should be suppressed regardless of theme.
142    pub fn with_no_color(mut self, no_color: bool) -> Self {
143        self.runtime.no_color = no_color;
144        self
145    }
146
147    /// Sets the measured terminal width.
148    pub fn with_width(mut self, width: usize) -> Self {
149        self.runtime.width = Some(width);
150        self
151    }
152
153    /// Sets whether the locale is known to support UTF-8.
154    pub fn with_locale_utf8(mut self, locale_utf8: bool) -> Self {
155        self.runtime.locale_utf8 = Some(locale_utf8);
156        self
157    }
158
159    /// Builds the runtime terminal facts.
160    pub fn build(self) -> RenderRuntime {
161        self.runtime
162    }
163}
164
165/// User-configurable settings for rendering CLI output.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
167pub struct HelpChromeSettings {
168    /// Border style override for help/guide tables.
169    pub table_chrome: HelpTableChrome,
170    /// Explicit indentation override for help entries.
171    pub entry_indent: Option<usize>,
172    /// Explicit gap override between help entry columns.
173    pub entry_gap: Option<usize>,
174    /// Explicit spacing override between help sections.
175    pub section_spacing: Option<usize>,
176}
177
178/// User-configurable settings for rendering CLI output.
179#[derive(Debug, Clone)]
180#[non_exhaustive]
181pub struct RenderSettings {
182    /// Preferred output format.
183    pub format: OutputFormat,
184    /// Whether `format` was chosen explicitly by the caller.
185    pub format_explicit: bool,
186    /// Preferred rendering mode.
187    pub mode: RenderMode,
188    /// Color behavior selection.
189    pub color: ColorMode,
190    /// Unicode behavior selection.
191    pub unicode: UnicodeMode,
192    /// Explicit width override for rendering.
193    pub width: Option<usize>,
194    /// Left margin applied to rendered blocks.
195    pub margin: usize,
196    /// Indentation width used for nested structures.
197    pub indent_size: usize,
198    /// Maximum list length rendered in compact form.
199    pub short_list_max: usize,
200    /// Maximum list length rendered in medium form before expanding further.
201    pub medium_list_max: usize,
202    /// Horizontal padding between grid columns.
203    pub grid_padding: usize,
204    /// Explicit grid column count override.
205    pub grid_columns: Option<usize>,
206    /// Relative weighting for adaptive grid columns.
207    pub column_weight: usize,
208    /// Overflow policy for table cells.
209    pub table_overflow: TableOverflow,
210    /// Border style for general table rendering.
211    pub table_border: TableBorderStyle,
212    /// Help/guide-specific chrome settings.
213    pub help_chrome: HelpChromeSettings,
214    /// Minimum width before stacked MREG columns are used.
215    pub mreg_stack_min_col_width: usize,
216    /// Threshold controlling when MREG content overflows into stacked mode.
217    pub mreg_stack_overflow_ratio: usize,
218    /// Selected theme name.
219    pub theme_name: String,
220    /// Cached resolved theme derived from `theme_name`.
221    ///
222    /// This stays crate-internal so external callers cannot create
223    /// contradictory `theme_name` / resolved-theme pairs.
224    pub(crate) theme: Option<ThemeDefinition>,
225    /// Per-token style overrides layered on top of the theme.
226    pub style_overrides: StyleOverrides,
227    /// Section frame style used for grouped chrome.
228    pub chrome_frame: SectionFrameStyle,
229    /// Placement policy for ruled section separators across sibling sections.
230    pub ruled_section_policy: RuledSectionPolicy,
231    /// Fallback behavior for semantic guide output.
232    pub guide_default_format: GuideDefaultFormat,
233    /// Runtime terminal facts used during auto-resolution.
234    pub runtime: RenderRuntime,
235}
236
237impl Default for RenderSettings {
238    fn default() -> Self {
239        Self {
240            format: OutputFormat::Auto,
241            format_explicit: false,
242            mode: RenderMode::Auto,
243            color: ColorMode::Auto,
244            unicode: UnicodeMode::Auto,
245            width: None,
246            margin: 0,
247            indent_size: 2,
248            short_list_max: 1,
249            medium_list_max: 5,
250            grid_padding: 4,
251            grid_columns: None,
252            column_weight: 3,
253            table_overflow: TableOverflow::Clip,
254            table_border: TableBorderStyle::Square,
255            help_chrome: HelpChromeSettings::default(),
256            mreg_stack_min_col_width: 10,
257            mreg_stack_overflow_ratio: 200,
258            theme_name: crate::ui::theme::DEFAULT_THEME_NAME.to_string(),
259            theme: None,
260            style_overrides: crate::ui::style::StyleOverrides::default(),
261            chrome_frame: SectionFrameStyle::Top,
262            ruled_section_policy: RuledSectionPolicy::PerSection,
263            guide_default_format: GuideDefaultFormat::Guide,
264            runtime: RenderRuntime::default(),
265        }
266    }
267}
268
269/// Builder for [`RenderSettings`].
270#[derive(Debug, Clone, Default)]
271pub struct RenderSettingsBuilder {
272    settings: RenderSettings,
273}
274
275impl RenderSettingsBuilder {
276    /// Creates a builder seeded with [`RenderSettings::default`].
277    pub fn new() -> Self {
278        Self::default()
279    }
280
281    /// Creates a builder seeded with [`RenderSettings::test_plain`].
282    pub fn plain(format: OutputFormat) -> Self {
283        Self {
284            settings: RenderSettings {
285                format,
286                format_explicit: false,
287                mode: RenderMode::Plain,
288                color: ColorMode::Never,
289                unicode: UnicodeMode::Never,
290                ..RenderSettings::default()
291            },
292        }
293    }
294
295    /// Sets the preferred output format.
296    pub fn with_format(mut self, format: OutputFormat) -> Self {
297        self.settings.format = format;
298        self
299    }
300
301    /// Sets whether the output format was chosen explicitly.
302    pub fn with_format_explicit(mut self, format_explicit: bool) -> Self {
303        self.settings.format_explicit = format_explicit;
304        self
305    }
306
307    /// Sets the preferred rendering mode.
308    pub fn with_mode(mut self, mode: RenderMode) -> Self {
309        self.settings.mode = mode;
310        self
311    }
312
313    /// Sets color behavior.
314    pub fn with_color(mut self, color: ColorMode) -> Self {
315        self.settings.color = color;
316        self
317    }
318
319    /// Sets Unicode behavior.
320    pub fn with_unicode(mut self, unicode: UnicodeMode) -> Self {
321        self.settings.unicode = unicode;
322        self
323    }
324
325    /// Sets an explicit width override.
326    pub fn with_width(mut self, width: usize) -> Self {
327        self.settings.width = Some(width);
328        self
329    }
330
331    /// Sets the left margin.
332    pub fn with_margin(mut self, margin: usize) -> Self {
333        self.settings.margin = margin;
334        self
335    }
336
337    /// Sets the indentation width for nested structures.
338    pub fn with_indent_size(mut self, indent_size: usize) -> Self {
339        self.settings.indent_size = indent_size;
340        self
341    }
342
343    /// Sets the overflow policy for table cells.
344    pub fn with_table_overflow(mut self, table_overflow: TableOverflow) -> Self {
345        self.settings.table_overflow = table_overflow;
346        self
347    }
348
349    /// Sets the general table border style.
350    pub fn with_table_border(mut self, table_border: TableBorderStyle) -> Self {
351        self.settings.table_border = table_border;
352        self
353    }
354
355    /// Sets the grouped help/guide chrome settings.
356    pub fn with_help_chrome(mut self, help_chrome: HelpChromeSettings) -> Self {
357        self.settings.help_chrome = help_chrome;
358        self
359    }
360
361    /// Sets the selected theme name.
362    pub fn with_theme_name(mut self, theme_name: impl Into<String>) -> Self {
363        self.settings.theme_name = theme_name.into();
364        self
365    }
366
367    /// Sets style overrides layered over the theme.
368    pub fn with_style_overrides(mut self, style_overrides: StyleOverrides) -> Self {
369        self.settings.style_overrides = style_overrides;
370        self
371    }
372
373    /// Sets the section frame style.
374    pub fn with_chrome_frame(mut self, chrome_frame: SectionFrameStyle) -> Self {
375        self.settings.chrome_frame = chrome_frame;
376        self
377    }
378
379    /// Sets how ruled separators are shared across sibling sections.
380    pub fn with_ruled_section_policy(mut self, ruled_section_policy: RuledSectionPolicy) -> Self {
381        self.settings.ruled_section_policy = ruled_section_policy;
382        self
383    }
384
385    /// Sets the default guide-rendering preference.
386    pub fn with_guide_default_format(mut self, guide_default_format: GuideDefaultFormat) -> Self {
387        self.settings.guide_default_format = guide_default_format;
388        self
389    }
390
391    /// Sets the runtime terminal facts used during auto-resolution.
392    pub fn with_runtime(mut self, runtime: RenderRuntime) -> Self {
393        self.settings.runtime = runtime;
394        self
395    }
396
397    /// Builds the render settings value.
398    pub fn build(self) -> RenderSettings {
399        self.settings
400    }
401}
402
403/// Default output format to use when guide rendering is not explicitly requested.
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
405pub enum GuideDefaultFormat {
406    /// Prefer semantic guide output when the caller did not request a format.
407    #[default]
408    Guide,
409    /// Inherit the caller-selected format without forcing guide mode.
410    Inherit,
411}
412
413impl GuideDefaultFormat {
414    /// Parses the guide fallback mode used when no explicit output format wins.
415    ///
416    /// # Examples
417    ///
418    /// ```
419    /// use osp_cli::ui::GuideDefaultFormat;
420    ///
421    /// assert_eq!(GuideDefaultFormat::parse("guide"), Some(GuideDefaultFormat::Guide));
422    /// assert_eq!(GuideDefaultFormat::parse("none"), Some(GuideDefaultFormat::Inherit));
423    /// assert_eq!(GuideDefaultFormat::parse("wat"), None);
424    /// ```
425    pub fn parse(value: &str) -> Option<Self> {
426        match value.trim().to_ascii_lowercase().as_str() {
427            "guide" => Some(Self::Guide),
428            "inherit" | "none" => Some(Self::Inherit),
429            _ => None,
430        }
431    }
432}
433
434/// Rendering backend selected for the current output pass.
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436pub enum RenderBackend {
437    /// Render without terminal-rich features.
438    Plain,
439    /// Render using ANSI and richer terminal affordances.
440    Rich,
441}
442
443/// Overflow strategy for table cell content.
444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub enum TableOverflow {
446    /// Leave overflow management to the terminal.
447    None,
448    /// Hard-clip overflowing cell content.
449    Clip,
450    /// Truncate overflowing content with an ellipsis marker.
451    Ellipsis,
452    /// Wrap overflowing content onto multiple lines.
453    Wrap,
454}
455
456impl TableOverflow {
457    /// Parses the table-cell overflow policy accepted by config and flags.
458    ///
459    /// # Examples
460    ///
461    /// ```
462    /// use osp_cli::ui::TableOverflow;
463    ///
464    /// assert_eq!(TableOverflow::parse("wrap"), Some(TableOverflow::Wrap));
465    /// assert_eq!(TableOverflow::parse("truncate"), Some(TableOverflow::Ellipsis));
466    /// assert_eq!(TableOverflow::parse("wat"), None);
467    /// ```
468    pub fn parse(value: &str) -> Option<Self> {
469        match value.trim().to_ascii_lowercase().as_str() {
470            "none" | "visible" => Some(Self::None),
471            "clip" | "hidden" | "crop" => Some(Self::Clip),
472            "ellipsis" | "truncate" => Some(Self::Ellipsis),
473            "wrap" | "wrapped" => Some(Self::Wrap),
474            _ => None,
475        }
476    }
477}
478
479/// Border style applied to rendered tables.
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
481pub enum TableBorderStyle {
482    /// Render tables without outer borders.
483    None,
484    /// Render tables with square box-drawing borders.
485    #[default]
486    Square,
487    /// Render tables with rounded box-drawing borders.
488    Round,
489}
490
491impl TableBorderStyle {
492    /// Parses the table border chrome accepted by config and flags.
493    ///
494    /// # Examples
495    ///
496    /// ```
497    /// use osp_cli::ui::TableBorderStyle;
498    ///
499    /// assert_eq!(TableBorderStyle::parse("box"), Some(TableBorderStyle::Square));
500    /// assert_eq!(TableBorderStyle::parse("rounded"), Some(TableBorderStyle::Round));
501    /// assert_eq!(TableBorderStyle::parse("wat"), None);
502    /// ```
503    pub fn parse(value: &str) -> Option<Self> {
504        match value.trim().to_ascii_lowercase().as_str() {
505            "none" | "plain" => Some(Self::None),
506            "square" | "box" | "boxed" => Some(Self::Square),
507            "round" | "rounded" => Some(Self::Round),
508            _ => None,
509        }
510    }
511}
512
513/// Border style override for help tables.
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
515pub enum HelpTableChrome {
516    /// Reuse the normal table border style.
517    Inherit,
518    /// Render help tables without box chrome.
519    #[default]
520    None,
521    /// Render help tables with square box-drawing borders.
522    Square,
523    /// Render help tables with rounded box-drawing borders.
524    Round,
525}
526
527impl HelpTableChrome {
528    /// Parses the help-table chrome override accepted by config.
529    ///
530    /// # Examples
531    ///
532    /// ```
533    /// use osp_cli::ui::HelpTableChrome;
534    ///
535    /// assert_eq!(HelpTableChrome::parse("inherit"), Some(HelpTableChrome::Inherit));
536    /// assert_eq!(HelpTableChrome::parse("plain"), Some(HelpTableChrome::None));
537    /// assert_eq!(HelpTableChrome::parse("wat"), None);
538    /// ```
539    pub fn parse(value: &str) -> Option<Self> {
540        match value.trim().to_ascii_lowercase().as_str() {
541            "inherit" => Some(Self::Inherit),
542            "none" | "plain" => Some(Self::None),
543            "square" | "box" | "boxed" => Some(Self::Square),
544            "round" | "rounded" => Some(Self::Round),
545            _ => None,
546        }
547    }
548
549    /// Resolves the concrete help-table border after applying the override.
550    ///
551    /// # Examples
552    ///
553    /// ```
554    /// use osp_cli::ui::{HelpTableChrome, TableBorderStyle};
555    ///
556    /// assert_eq!(
557    ///     HelpTableChrome::Inherit.resolve(TableBorderStyle::Round),
558    ///     TableBorderStyle::Round
559    /// );
560    /// assert_eq!(
561    ///     HelpTableChrome::Square.resolve(TableBorderStyle::None),
562    ///     TableBorderStyle::Square
563    /// );
564    /// ```
565    pub fn resolve(self, table_border: TableBorderStyle) -> TableBorderStyle {
566        match self {
567            Self::Inherit => table_border,
568            Self::None => TableBorderStyle::None,
569            Self::Square => TableBorderStyle::Square,
570            Self::Round => TableBorderStyle::Round,
571        }
572    }
573}
574
575impl RenderSettings {
576    /// Starts building render settings from the default UI baseline.
577    pub fn builder() -> RenderSettingsBuilder {
578        RenderSettingsBuilder::new()
579    }
580
581    /// Shared plain-mode baseline for deterministic tests and examples.
582    ///
583    /// This keeps docs and tests from duplicating a large struct literal every
584    /// time they need a stable no-color rendering baseline.
585    ///
586    /// # Examples
587    ///
588    /// ```
589    /// use osp_cli::core::output::OutputFormat;
590    /// use osp_cli::ui::{RenderBackend, RenderSettings};
591    ///
592    /// let resolved = RenderSettings::test_plain(OutputFormat::Json)
593    ///     .resolve_render_settings();
594    ///
595    /// assert_eq!(resolved.backend, RenderBackend::Plain);
596    /// assert!(!resolved.color);
597    /// assert!(!resolved.unicode);
598    /// ```
599    pub fn test_plain(format: OutputFormat) -> Self {
600        RenderSettingsBuilder::plain(format).build()
601    }
602
603    /// Returns whether guide output should be preferred for the current
604    /// settings.
605    ///
606    /// This only falls back to guide mode when the caller did not explicitly
607    /// request another format.
608    ///
609    /// # Examples
610    ///
611    /// ```
612    /// use osp_cli::core::output::OutputFormat;
613    /// use osp_cli::ui::{GuideDefaultFormat, RenderSettings};
614    ///
615    /// let mut settings = RenderSettings::test_plain(OutputFormat::Auto);
616    /// settings.format_explicit = false;
617    /// settings.guide_default_format = GuideDefaultFormat::Guide;
618    /// assert!(settings.prefers_guide_rendering());
619    ///
620    /// settings.format_explicit = true;
621    /// settings.format = OutputFormat::Json;
622    /// assert!(!settings.prefers_guide_rendering());
623    /// ```
624    pub fn prefers_guide_rendering(&self) -> bool {
625        matches!(self.format, OutputFormat::Guide)
626            || (!self.format_explicit
627                && matches!(self.guide_default_format, GuideDefaultFormat::Guide))
628    }
629}
630
631/// Renders rows using the configured output format.
632pub fn render_rows(rows: &[Row], settings: &RenderSettings) -> String {
633    render_output(
634        &OutputResult {
635            items: OutputItems::Rows(rows.to_vec()),
636            document: None,
637            meta: Default::default(),
638        },
639        settings,
640    )
641}
642
643/// Renders a structured output result using the configured output format.
644pub fn render_output(output: &OutputResult, settings: &RenderSettings) -> String {
645    let plan = settings.resolve_render_plan(output);
646    if matches!(plan.format, OutputFormat::Markdown)
647        && let Some(guide) = GuideView::try_from_output_result(output)
648    {
649        return guide.to_markdown_with_width(plan.render.width);
650    }
651    let document = format::build_document_from_output_plan(output, &plan);
652    renderer::render_document(&document, plan.render)
653}
654
655fn render_guide_document(document: &Document, settings: &RenderSettings) -> String {
656    let mut rendered = render_document_resolved(document, settings.resolve_render_settings());
657    if !rendered.ends_with('\n') {
658        rendered.push('\n');
659    }
660    rendered
661}
662
663pub(crate) fn render_guide_view_with_options(
664    guide: &GuideView,
665    settings: &RenderSettings,
666    options: crate::ui::format::help::GuideRenderOptions<'_>,
667) -> String {
668    if matches!(
669        format::resolve_output_format(&guide.to_output_result(), settings),
670        OutputFormat::Guide
671    ) {
672        let document = crate::ui::format::help::build_guide_document_from_view(guide, options);
673        return render_guide_document(&document, settings);
674    }
675
676    render_output(&guide.to_output_result(), settings)
677}
678
679pub(crate) fn render_guide_payload(
680    config: &crate::config::ResolvedConfig,
681    settings: &RenderSettings,
682    guide: &GuideView,
683) -> String {
684    render_guide_payload_with_layout(
685        guide,
686        settings,
687        crate::ui::presentation::help_layout(config),
688    )
689}
690
691pub(crate) fn render_guide_payload_with_layout(
692    guide: &GuideView,
693    settings: &RenderSettings,
694    layout: crate::ui::presentation::HelpLayout,
695) -> String {
696    let guide_settings = settings.resolve_guide_render_settings();
697    render_guide_view_with_options(
698        guide,
699        settings,
700        crate::ui::format::help::GuideRenderOptions {
701            title_prefix: None,
702            layout,
703            guide: guide_settings,
704            panel_kind: None,
705        },
706    )
707}
708
709pub(crate) fn render_guide_output_with_options(
710    output: &OutputResult,
711    settings: &RenderSettings,
712    options: crate::ui::format::help::GuideRenderOptions<'_>,
713) -> String {
714    if matches!(
715        format::resolve_output_format(output, settings),
716        OutputFormat::Guide
717    ) && let Some(guide) = GuideView::try_from_output_result(output)
718    {
719        return render_guide_view_with_options(&guide, settings, options);
720    }
721
722    render_output(output, settings)
723}
724
725pub(crate) fn guide_render_options<'a>(
726    config: &'a crate::config::ResolvedConfig,
727    settings: &'a RenderSettings,
728) -> crate::ui::format::help::GuideRenderOptions<'a> {
729    let guide_settings = settings.resolve_guide_render_settings();
730    crate::ui::format::help::GuideRenderOptions {
731        title_prefix: None,
732        layout: crate::ui::presentation::help_layout(config),
733        guide: guide_settings,
734        panel_kind: None,
735    }
736}
737
738pub(crate) fn render_structured_output(
739    config: &crate::config::ResolvedConfig,
740    settings: &RenderSettings,
741    output: &OutputResult,
742) -> String {
743    if GuideView::try_from_output_result(output).is_some() {
744        return render_guide_output_with_options(
745            output,
746            settings,
747            guide_render_options(config, settings),
748        );
749    }
750    render_output(output, settings)
751}
752
753/// Renders a document directly with the resolved UI settings.
754pub fn render_document(document: &Document, settings: &RenderSettings) -> String {
755    let resolved = settings.resolve_render_settings();
756    renderer::render_document(document, resolved)
757}
758
759pub(crate) fn render_document_resolved(
760    document: &Document,
761    settings: ResolvedRenderSettings,
762) -> String {
763    renderer::render_document(document, settings)
764}
765
766/// Renders rows in plain copy-safe form.
767///
768/// Copy helpers intentionally bypass ANSI and rich terminal styling so the
769/// clipboard gets stable plain text.
770///
771/// # Examples
772///
773/// ```
774/// use osp_cli::core::output::OutputFormat;
775/// use osp_cli::row;
776/// use osp_cli::ui::{RenderSettings, render_rows_for_copy};
777///
778/// let rendered = render_rows_for_copy(
779///     &[row! { "uid" => "alice" }],
780///     &RenderSettings::test_plain(OutputFormat::Json),
781/// );
782///
783/// assert!(rendered.contains("\"uid\": \"alice\""));
784/// ```
785pub fn render_rows_for_copy(rows: &[Row], settings: &RenderSettings) -> String {
786    render_output_for_copy(
787        &OutputResult {
788            items: OutputItems::Rows(rows.to_vec()),
789            document: None,
790            meta: Default::default(),
791        },
792        settings,
793    )
794}
795
796/// Renders an output result in plain copy-safe form.
797pub fn render_output_for_copy(output: &OutputResult, settings: &RenderSettings) -> String {
798    let copy_settings = settings.plain_copy_settings();
799    let plan = copy_settings.resolve_render_plan(output);
800    if matches!(plan.format, OutputFormat::Markdown)
801        && let Some(guide) = GuideView::try_from_output_result(output)
802    {
803        return guide.to_markdown_with_width(plan.render.width);
804    }
805    let document = format::build_document_from_output_plan(output, &plan);
806    renderer::render_document(&document, plan.render)
807}
808
809/// Renders a document in plain copy-safe form.
810pub fn render_document_for_copy(document: &Document, settings: &RenderSettings) -> String {
811    let copy_settings = settings.plain_copy_settings();
812    let resolved = copy_settings.resolve_render_settings();
813    renderer::render_document(document, resolved)
814}
815
816/// Copies rendered rows to the configured clipboard service.
817pub fn copy_rows_to_clipboard(
818    rows: &[Row],
819    settings: &RenderSettings,
820    clipboard: &clipboard::ClipboardService,
821) -> Result<(), clipboard::ClipboardError> {
822    copy_output_to_clipboard(
823        &OutputResult {
824            items: OutputItems::Rows(rows.to_vec()),
825            document: None,
826            meta: Default::default(),
827        },
828        settings,
829        clipboard,
830    )
831}
832
833/// Copies rendered output to the configured clipboard service.
834pub fn copy_output_to_clipboard(
835    output: &OutputResult,
836    settings: &RenderSettings,
837    clipboard: &clipboard::ClipboardService,
838) -> Result<(), clipboard::ClipboardError> {
839    let text = render_output_for_copy(output, settings);
840    clipboard.copy_text(&text)
841}
842
843#[cfg(test)]
844mod tests {
845    use super::{
846        GuideDefaultFormat, HelpChromeSettings, HelpTableChrome, RenderBackend, RenderRuntime,
847        RenderSettings, RenderSettingsBuilder, TableBorderStyle, TableOverflow, format,
848        render_document, render_document_for_copy, render_output, render_output_for_copy,
849        render_rows, render_rows_for_copy,
850    };
851    use crate::core::output::{ColorMode, OutputFormat, RenderMode, UnicodeMode};
852    use crate::core::output_model::OutputResult;
853    use crate::core::row::Row;
854    use crate::guide::GuideView;
855    use crate::ui::document::{Block, MregValue, TableStyle};
856    use serde_json::json;
857
858    fn settings(format: OutputFormat) -> RenderSettings {
859        RenderSettings {
860            mode: RenderMode::Auto,
861            ..RenderSettings::test_plain(format)
862        }
863    }
864
865    #[test]
866    fn document_builder_selects_auto_and_explicit_block_shapes_unit() {
867        let value_rows = vec![{
868            let mut row = Row::new();
869            row.insert("value".to_string(), json!("hello"));
870            row
871        }];
872        let document = format::build_document(&value_rows, &settings(OutputFormat::Auto));
873        assert!(matches!(document.blocks[0], Block::Value(_)));
874
875        let mreg_rows = vec![{
876            let mut row = Row::new();
877            row.insert("uid".to_string(), json!("oistes"));
878            row
879        }];
880        let document = format::build_document(&mreg_rows, &settings(OutputFormat::Auto));
881        assert!(matches!(document.blocks[0], Block::Mreg(_)));
882
883        let table_rows = vec![
884            {
885                let mut row = Row::new();
886                row.insert("uid".to_string(), json!("one"));
887                row
888            },
889            {
890                let mut row = Row::new();
891                row.insert("uid".to_string(), json!("two"));
892                row
893            },
894        ];
895        let document = format::build_document(&table_rows, &settings(OutputFormat::Auto));
896        assert!(matches!(document.blocks[0], Block::Table(_)));
897
898        let rich_rows = vec![{
899            let mut row = Row::new();
900            row.insert("uid".to_string(), json!("oistes"));
901            row.insert("groups".to_string(), json!(["a", "b"]));
902            row
903        }];
904        let document = format::build_document(&rich_rows, &settings(OutputFormat::Mreg));
905        let Block::Mreg(block) = &document.blocks[0] else {
906            panic!("expected mreg block");
907        };
908        assert_eq!(block.rows.len(), 1);
909        assert!(
910            block.rows[0]
911                .entries
912                .iter()
913                .any(|entry| matches!(entry.value, MregValue::Scalar(_)))
914        );
915        assert!(
916            block.rows[0]
917                .entries
918                .iter()
919                .any(|entry| matches!(entry.value, MregValue::VerticalList(_)))
920        );
921
922        let markdown_rows = vec![{
923            let mut row = Row::new();
924            row.insert("uid".to_string(), json!("oistes"));
925            row
926        }];
927        let document = format::build_document(&markdown_rows, &settings(OutputFormat::Markdown));
928        let Block::Table(table) = &document.blocks[0] else {
929            panic!("expected table block");
930        };
931        assert_eq!(table.style, TableStyle::Markdown);
932    }
933
934    #[test]
935    fn semantic_guide_markdown_output_and_copy_remain_section_based_unit() {
936        let output =
937            GuideView::from_text("Usage: osp history <COMMAND>\n\nCommands:\n  list  Show\n")
938                .to_output_result();
939        let settings = RenderSettings {
940            format: OutputFormat::Markdown,
941            format_explicit: true,
942            ..settings(OutputFormat::Markdown)
943        };
944
945        let rendered = render_output(&output, &settings);
946        let copied = render_output_for_copy(&output, &settings);
947
948        for text in [&rendered, &copied] {
949            assert!(text.contains("## Usage"));
950            assert!(text.contains("## Commands"));
951            assert!(text.contains("- `list` Show"));
952            assert!(!text.contains("| name"));
953        }
954        assert!(!copied.contains("\x1b["));
955    }
956
957    #[test]
958    fn render_builders_and_parse_helpers_cover_configuration_surface_unit() {
959        let runtime = RenderRuntime::builder()
960            .with_stdout_is_tty(true)
961            .with_terminal("xterm-256color")
962            .with_no_color(true)
963            .with_width(98)
964            .with_locale_utf8(false)
965            .build();
966        assert_eq!(
967            runtime,
968            RenderRuntime {
969                stdout_is_tty: true,
970                terminal: Some("xterm-256color".to_string()),
971                no_color: true,
972                width: Some(98),
973                locale_utf8: Some(false),
974            }
975        );
976
977        let settings = RenderSettings::builder()
978            .with_format(OutputFormat::Markdown)
979            .with_format_explicit(true)
980            .with_mode(RenderMode::Rich)
981            .with_color(ColorMode::Always)
982            .with_unicode(UnicodeMode::Auto)
983            .with_width(98)
984            .with_margin(2)
985            .with_indent_size(4)
986            .with_table_overflow(TableOverflow::Wrap)
987            .with_table_border(TableBorderStyle::Round)
988            .with_help_chrome(HelpChromeSettings {
989                table_chrome: HelpTableChrome::Inherit,
990                ..HelpChromeSettings::default()
991            })
992            .with_theme_name("dracula")
993            .with_style_overrides(Default::default())
994            .with_chrome_frame(crate::ui::SectionFrameStyle::Round)
995            .with_guide_default_format(GuideDefaultFormat::Inherit)
996            .with_runtime(runtime.clone())
997            .build();
998        assert_eq!(settings.format, OutputFormat::Markdown);
999        assert!(settings.format_explicit);
1000        assert_eq!(settings.mode, RenderMode::Rich);
1001        assert_eq!(settings.color, ColorMode::Always);
1002        assert_eq!(settings.unicode, UnicodeMode::Auto);
1003        assert_eq!(settings.width, Some(98));
1004        assert_eq!(settings.margin, 2);
1005        assert_eq!(settings.indent_size, 4);
1006        assert_eq!(settings.table_overflow, TableOverflow::Wrap);
1007        assert_eq!(settings.table_border, TableBorderStyle::Round);
1008        assert_eq!(settings.help_chrome.table_chrome, HelpTableChrome::Inherit);
1009        assert_eq!(settings.theme_name, "dracula");
1010        assert_eq!(settings.chrome_frame, crate::ui::SectionFrameStyle::Round);
1011        assert_eq!(settings.guide_default_format, GuideDefaultFormat::Inherit);
1012        assert_eq!(settings.runtime, runtime);
1013
1014        let plain = RenderSettingsBuilder::plain(OutputFormat::Json).build();
1015        assert_eq!(plain.mode, RenderMode::Plain);
1016        assert_eq!(plain.color, ColorMode::Never);
1017        assert_eq!(plain.unicode, UnicodeMode::Never);
1018
1019        assert_eq!(
1020            GuideDefaultFormat::parse("none"),
1021            Some(GuideDefaultFormat::Inherit)
1022        );
1023        assert_eq!(GuideDefaultFormat::parse("wat"), None);
1024        assert_eq!(
1025            HelpTableChrome::parse("round"),
1026            Some(HelpTableChrome::Round)
1027        );
1028        assert_eq!(HelpTableChrome::parse("wat"), None);
1029        assert_eq!(
1030            HelpTableChrome::Inherit.resolve(TableBorderStyle::Round),
1031            TableBorderStyle::Round
1032        );
1033        assert_eq!(
1034            HelpTableChrome::None.resolve(TableBorderStyle::Square),
1035            TableBorderStyle::None
1036        );
1037        assert_eq!(
1038            HelpTableChrome::Square.resolve(TableBorderStyle::None),
1039            TableBorderStyle::Square
1040        );
1041        assert_eq!(
1042            TableBorderStyle::parse("none"),
1043            Some(TableBorderStyle::None)
1044        );
1045        assert_eq!(
1046            TableBorderStyle::parse("box"),
1047            Some(TableBorderStyle::Square)
1048        );
1049        assert_eq!(
1050            TableBorderStyle::parse("square"),
1051            Some(TableBorderStyle::Square)
1052        );
1053        assert_eq!(
1054            TableBorderStyle::parse("round"),
1055            Some(TableBorderStyle::Round)
1056        );
1057        assert_eq!(
1058            TableBorderStyle::parse("rounded"),
1059            Some(TableBorderStyle::Round)
1060        );
1061        assert_eq!(TableBorderStyle::parse("mystery"), None);
1062        assert_eq!(TableOverflow::parse("visible"), Some(TableOverflow::None));
1063        assert_eq!(TableOverflow::parse("crop"), Some(TableOverflow::Clip));
1064        assert_eq!(
1065            TableOverflow::parse("truncate"),
1066            Some(TableOverflow::Ellipsis)
1067        );
1068        assert_eq!(TableOverflow::parse("wrapped"), Some(TableOverflow::Wrap));
1069        assert_eq!(TableOverflow::parse("other"), None);
1070    }
1071
1072    #[test]
1073    fn render_resolution_covers_public_helpers_mode_runtime_and_force_rules_unit() {
1074        let rows = vec![{
1075            let mut row = Row::new();
1076            row.insert("uid".to_string(), json!("alice"));
1077            row
1078        }];
1079        let rendered = render_rows(&rows, &settings(OutputFormat::Table));
1080        assert!(rendered.contains("uid"));
1081        assert!(rendered.contains("alice"));
1082
1083        let dumb_terminal = RenderSettings {
1084            mode: RenderMode::Rich,
1085            color: ColorMode::Auto,
1086            unicode: UnicodeMode::Auto,
1087            width: Some(0),
1088            grid_columns: Some(0),
1089            runtime: RenderRuntime {
1090                stdout_is_tty: true,
1091                terminal: Some("dumb".to_string()),
1092                no_color: false,
1093                width: Some(0),
1094                locale_utf8: Some(true),
1095            },
1096            ..RenderSettings::test_plain(OutputFormat::Table)
1097        };
1098        let dumb_resolved = dumb_terminal.resolve_render_settings();
1099        assert_eq!(dumb_resolved.backend, RenderBackend::Rich);
1100        assert!(dumb_resolved.color);
1101        assert!(!dumb_resolved.unicode);
1102        assert_eq!(dumb_resolved.width, None);
1103        assert_eq!(dumb_resolved.grid_columns, None);
1104
1105        let locale_false = RenderSettings {
1106            mode: RenderMode::Rich,
1107            color: ColorMode::Auto,
1108            unicode: UnicodeMode::Auto,
1109            runtime: RenderRuntime {
1110                stdout_is_tty: true,
1111                terminal: Some("xterm-256color".to_string()),
1112                no_color: false,
1113                width: Some(72),
1114                locale_utf8: Some(false),
1115            },
1116            ..RenderSettings::test_plain(OutputFormat::Table)
1117        };
1118        let locale_resolved = locale_false.resolve_render_settings();
1119        assert!(locale_resolved.color);
1120        assert!(!locale_resolved.unicode);
1121        assert_eq!(locale_resolved.width, Some(72));
1122
1123        let plain = RenderSettings {
1124            format: OutputFormat::Table,
1125            color: ColorMode::Always,
1126            unicode: UnicodeMode::Always,
1127            ..RenderSettings::test_plain(OutputFormat::Table)
1128        };
1129        let resolved = plain.resolve_render_settings();
1130        assert_eq!(resolved.backend, RenderBackend::Plain);
1131        assert!(!resolved.color);
1132        assert!(!resolved.unicode);
1133
1134        let rich = RenderSettings {
1135            format: OutputFormat::Table,
1136            mode: RenderMode::Rich,
1137            color: ColorMode::Always,
1138            unicode: UnicodeMode::Always,
1139            ..RenderSettings::test_plain(OutputFormat::Table)
1140        };
1141        let resolved = rich.resolve_render_settings();
1142        assert_eq!(resolved.backend, RenderBackend::Rich);
1143        assert!(resolved.color);
1144        assert!(resolved.unicode);
1145        let auto = RenderSettings {
1146            mode: RenderMode::Auto,
1147            color: ColorMode::Auto,
1148            unicode: UnicodeMode::Auto,
1149            runtime: super::RenderRuntime {
1150                stdout_is_tty: true,
1151                terminal: Some("dumb".to_string()),
1152                no_color: false,
1153                width: Some(72),
1154                locale_utf8: Some(false),
1155            },
1156            ..RenderSettings::test_plain(OutputFormat::Table)
1157        };
1158        let resolved = auto.resolve_render_settings();
1159        assert_eq!(resolved.backend, RenderBackend::Plain);
1160        assert!(!resolved.color);
1161        assert!(!resolved.unicode);
1162        assert_eq!(resolved.width, Some(72));
1163
1164        let forced_color = RenderSettings {
1165            mode: RenderMode::Auto,
1166            color: ColorMode::Always,
1167            unicode: UnicodeMode::Auto,
1168            runtime: super::RenderRuntime {
1169                stdout_is_tty: false,
1170                terminal: Some("xterm-256color".to_string()),
1171                no_color: false,
1172                width: Some(80),
1173                locale_utf8: Some(true),
1174            },
1175            ..RenderSettings::test_plain(OutputFormat::Table)
1176        };
1177        let resolved = forced_color.resolve_render_settings();
1178        assert_eq!(resolved.backend, RenderBackend::Rich);
1179        assert!(resolved.color);
1180
1181        let forced_unicode = RenderSettings {
1182            mode: RenderMode::Auto,
1183            color: ColorMode::Auto,
1184            unicode: UnicodeMode::Always,
1185            runtime: super::RenderRuntime {
1186                stdout_is_tty: false,
1187                terminal: Some("dumb".to_string()),
1188                no_color: true,
1189                width: Some(64),
1190                locale_utf8: Some(false),
1191            },
1192            ..RenderSettings::test_plain(OutputFormat::Table)
1193        };
1194        let resolved = forced_unicode.resolve_render_settings();
1195        assert_eq!(resolved.backend, RenderBackend::Rich);
1196        assert!(!resolved.color);
1197        assert!(resolved.unicode);
1198
1199        let guide_settings = RenderSettings {
1200            help_chrome: HelpChromeSettings {
1201                table_chrome: HelpTableChrome::Inherit,
1202                entry_indent: Some(4),
1203                entry_gap: Some(3),
1204                section_spacing: Some(0),
1205            },
1206            table_border: TableBorderStyle::Round,
1207            chrome_frame: crate::ui::SectionFrameStyle::TopBottom,
1208            ..RenderSettings::test_plain(OutputFormat::Guide)
1209        };
1210        let guide_resolved = guide_settings.resolve_guide_render_settings();
1211        assert_eq!(
1212            guide_resolved.frame_style,
1213            crate::ui::SectionFrameStyle::TopBottom
1214        );
1215        assert_eq!(
1216            guide_resolved.help_chrome.table_border,
1217            TableBorderStyle::Round
1218        );
1219        assert_eq!(guide_resolved.help_chrome.entry_indent, Some(4));
1220        assert_eq!(guide_resolved.help_chrome.entry_gap, Some(3));
1221        assert_eq!(guide_resolved.help_chrome.section_spacing, Some(0));
1222
1223        let mreg_settings = RenderSettings {
1224            short_list_max: 0,
1225            medium_list_max: 0,
1226            indent_size: 0,
1227            mreg_stack_min_col_width: 0,
1228            mreg_stack_overflow_ratio: 10,
1229            ..RenderSettings::test_plain(OutputFormat::Mreg)
1230        };
1231        let mreg_resolved = mreg_settings.resolve_mreg_build_settings();
1232        assert_eq!(mreg_resolved.short_list_max, 1);
1233        assert_eq!(mreg_resolved.medium_list_max, 2);
1234        assert_eq!(mreg_resolved.indent_size, 1);
1235        assert_eq!(mreg_resolved.stack_min_col_width, 1);
1236        assert_eq!(mreg_resolved.stack_overflow_ratio, 100);
1237    }
1238
1239    #[test]
1240    fn copy_helpers_force_plain_copy_mode_for_rows_documents_and_json_unit() {
1241        let table_rows = vec![{
1242            let mut row = Row::new();
1243            row.insert("uid".to_string(), json!("oistes"));
1244            row.insert(
1245                "description".to_string(),
1246                json!("very long text that will be shown"),
1247            );
1248            row
1249        }];
1250        let rich_table = RenderSettings {
1251            format: OutputFormat::Table,
1252            mode: RenderMode::Rich,
1253            color: ColorMode::Always,
1254            unicode: UnicodeMode::Always,
1255            ..RenderSettings::test_plain(OutputFormat::Table)
1256        };
1257        let table_copy = render_rows_for_copy(&table_rows, &rich_table);
1258        assert!(!table_copy.contains("\x1b["));
1259        assert!(!table_copy.contains('┌'));
1260        assert!(table_copy.contains('+'));
1261
1262        let value_rows = vec![{
1263            let mut row = Row::new();
1264            row.insert("value".to_string(), json!("hello"));
1265            row
1266        }];
1267        let rich_value = RenderSettings {
1268            mode: RenderMode::Rich,
1269            color: ColorMode::Always,
1270            unicode: UnicodeMode::Always,
1271            ..RenderSettings::test_plain(OutputFormat::Value)
1272        };
1273        let value_copy = render_rows_for_copy(&value_rows, &rich_value);
1274        assert_eq!(value_copy.trim(), "hello");
1275        assert!(!value_copy.contains("\x1b["));
1276
1277        let document = crate::ui::Document {
1278            blocks: vec![Block::Line(crate::ui::LineBlock {
1279                parts: vec![crate::ui::LinePart {
1280                    text: "hello".to_string(),
1281                    token: None,
1282                }],
1283            })],
1284        };
1285        let rich_document = RenderSettings {
1286            mode: RenderMode::Rich,
1287            color: ColorMode::Always,
1288            unicode: UnicodeMode::Always,
1289            ..RenderSettings::test_plain(OutputFormat::Table)
1290        };
1291        let rich = render_document(&document, &rich_document);
1292        let copied = render_document_for_copy(&document, &rich_document);
1293
1294        assert!(rich.contains("hello"));
1295        assert!(copied.contains("hello"));
1296        assert!(!copied.contains("\x1b["));
1297
1298        let json_rows = vec![{
1299            let mut row = Row::new();
1300            row.insert("uid".to_string(), json!("alice"));
1301            row.insert("count".to_string(), json!(2));
1302            row
1303        }];
1304        let json_settings = RenderSettings {
1305            format: OutputFormat::Json,
1306            mode: RenderMode::Rich,
1307            color: ColorMode::Always,
1308            unicode: UnicodeMode::Always,
1309            ..RenderSettings::test_plain(OutputFormat::Json)
1310        };
1311
1312        let output = OutputResult::from_rows(json_rows);
1313        let rendered = render_output(&output, &json_settings);
1314        let copied = render_output_for_copy(&output, &json_settings);
1315
1316        assert!(rendered.contains("\"uid\""));
1317        assert!(rendered.contains("\x1b["));
1318        assert_eq!(
1319            copied,
1320            "[\n  {\n    \"uid\": \"alice\",\n    \"count\": 2\n  }\n]\n"
1321        );
1322        assert!(!copied.contains("\x1b["));
1323    }
1324}