Skip to main content

osp_cli/ui/
mod.rs

1#![allow(missing_docs)]
2
3//! Canonical UI pipeline.
4//!
5//! This module owns planning, lowering, and emission for human-facing output.
6//! It is intentionally small in the core implementation slice:
7//!
8//! - one planner decides the effective output format
9//! - one human-facing document IR carries structure
10//! - one lowering pass turns payloads into that IR
11//! - one emitter family renders terminal or markdown output
12//! - sidecar subsystems such as messages live in their own folders
13
14pub mod clipboard;
15pub mod interact;
16pub mod messages;
17pub mod section_chrome;
18pub mod style;
19pub mod theme;
20pub mod theme_catalog;
21
22mod chrome;
23mod doc;
24mod emit;
25mod lower;
26mod plan;
27pub(crate) mod settings;
28mod text;
29
30use crate::config::ResolvedConfig;
31use crate::core::output::OutputFormat;
32use crate::core::output_model::OutputResult;
33use crate::guide::{GuideSection, GuideSectionKind, GuideView};
34
35pub(crate) use messages::{
36    render_messages_from_settings as render_messages, render_messages_without_config,
37};
38pub(crate) use plan::plan_output;
39pub use settings::RenderBackend;
40#[allow(unused_imports)]
41pub use settings::{
42    GuideDefaultFormat, HelpChromeSettings, HelpLayout, HelpTableChrome, PresentationEffect,
43    RenderProfile, RenderRuntime, RenderRuntimeBuilder, RenderSettings, RenderSettingsBuilder,
44    ResolvedHelpChromeSettings, ResolvedRenderSettings, TableBorderStyle, TableOverflow,
45    UiPresentation, help_layout_from_config, resolve_settings,
46};
47pub(crate) use settings::{build_presentation_defaults_layer, explain_presentation_effect};
48pub use style::{StyleOverrides, StyleToken, ThemeStyler};
49pub(crate) use text::visible_inline_text;
50pub use theme::DEFAULT_THEME_NAME;
51pub use theme_catalog as theme_loader;
52
53#[derive(Debug, Clone, Copy)]
54pub(crate) struct StructuredGuideRenderOptions<'a> {
55    pub(crate) source_guide: Option<&'a GuideView>,
56    pub(crate) layout: HelpLayout,
57    pub(crate) title_prefix: Option<&'a str>,
58    pub(crate) show_footer_rule: Option<bool>,
59}
60
61fn render_output_with_profile(
62    output: &OutputResult,
63    settings: &RenderSettings,
64    profile: RenderProfile,
65) -> String {
66    let plan = plan_output(output, settings, profile);
67    emit::emit_doc(
68        &lower::lower_output(output, &plan),
69        plan.format,
70        &plan.settings,
71    )
72}
73
74pub fn render_rows(rows: &[crate::core::row::Row], settings: &RenderSettings) -> String {
75    render_output(
76        &OutputResult {
77            items: crate::core::output_model::OutputItems::Rows(rows.to_vec()),
78            document: None,
79            meta: Default::default(),
80        },
81        settings,
82    )
83}
84
85/// Renders structured output using the canonical UI pipeline.
86///
87/// # Examples
88///
89/// ```
90/// use osp_cli::core::output::OutputFormat;
91/// use osp_cli::core::output_model::OutputResult;
92/// use osp_cli::row;
93/// use osp_cli::ui::{RenderSettings, render_output};
94///
95/// let output = OutputResult::from_rows(vec![row! {
96///     "uid" => "alice",
97///     "mail" => "a@example.com",
98/// }]);
99/// let rendered = render_output(&output, &RenderSettings::test_plain(OutputFormat::Markdown));
100///
101/// assert!(rendered.contains("| uid"));
102/// assert!(rendered.contains("alice"));
103/// ```
104pub fn render_output(output: &OutputResult, settings: &RenderSettings) -> String {
105    render_output_with_profile(output, settings, RenderProfile::Normal)
106}
107
108pub fn render_output_for_copy(output: &OutputResult, settings: &RenderSettings) -> String {
109    render_output_with_profile(output, settings, RenderProfile::CopySafe)
110}
111
112pub(crate) fn render_json_value(value: &serde_json::Value, settings: &RenderSettings) -> String {
113    let render_settings = settings.plain_copy_settings();
114    let resolved = resolve_settings(&render_settings, RenderProfile::CopySafe);
115    emit::emit_doc(
116        &doc::Doc {
117            blocks: vec![doc::Block::Json(doc::JsonBlock {
118                text: serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string()),
119            })],
120        },
121        OutputFormat::Json,
122        &resolved,
123    )
124}
125
126pub(crate) fn render_structured_output(
127    config: &ResolvedConfig,
128    settings: &RenderSettings,
129    output: &OutputResult,
130) -> String {
131    render_structured_output_with_config(output, None, settings, config)
132}
133
134pub(crate) fn copy_output_to_clipboard(
135    output: &OutputResult,
136    settings: &RenderSettings,
137    clipboard: &clipboard::ClipboardService,
138) -> Result<(), clipboard::ClipboardError> {
139    clipboard.copy_text(&render_output_for_copy(output, settings))
140}
141
142#[cfg(test)]
143pub(crate) fn render_structured_output_with_layout(
144    output: &OutputResult,
145    settings: &RenderSettings,
146    layout: HelpLayout,
147) -> String {
148    render_structured_output_with_source_guide(output, None, settings, layout)
149}
150
151pub(crate) fn render_structured_output_with_source_guide(
152    output: &OutputResult,
153    source_guide: Option<&GuideView>,
154    settings: &RenderSettings,
155    layout: HelpLayout,
156) -> String {
157    render_structured_output_with_guide_options(
158        output,
159        settings,
160        StructuredGuideRenderOptions {
161            source_guide,
162            layout,
163            title_prefix: None,
164            show_footer_rule: None,
165        },
166    )
167}
168
169pub(crate) fn render_structured_output_with_config(
170    output: &OutputResult,
171    source_guide: Option<&GuideView>,
172    settings: &RenderSettings,
173    config: &ResolvedConfig,
174) -> String {
175    render_structured_output_with_guide_options(
176        output,
177        settings,
178        StructuredGuideRenderOptions {
179            source_guide,
180            layout: help_layout_from_config(config),
181            title_prefix: None,
182            show_footer_rule: None,
183        },
184    )
185}
186
187pub(crate) fn render_structured_output_with_guide_options(
188    output: &OutputResult,
189    settings: &RenderSettings,
190    options: StructuredGuideRenderOptions<'_>,
191) -> String {
192    let plan = plan_output(output, settings, RenderProfile::Normal);
193    if !matches!(plan.format, OutputFormat::Guide | OutputFormat::Markdown) {
194        return emit::emit_doc(
195            &lower::lower_output(output, &plan),
196            plan.format,
197            &plan.settings,
198        );
199    }
200
201    let Some(guide) = options
202        .source_guide
203        .cloned()
204        .or_else(|| GuideView::try_from_output_result(output))
205        .or_else(|| GuideView::try_from_row_projection(output))
206    else {
207        return render_output(output, settings);
208    };
209
210    let guide = apply_title_prefix(&guide, options.title_prefix);
211    let guide_output = guide.to_output_result();
212    let guide_plan = plan_output(&guide_output, settings, RenderProfile::Normal);
213
214    if matches!(guide_plan.format, OutputFormat::Markdown) {
215        emit::emit_doc(
216            &lower::lower_output(&guide_output, &guide_plan),
217            OutputFormat::Markdown,
218            &guide_plan.settings,
219        )
220    } else {
221        emit::emit_doc(
222            &lower::lower_guide_help_layout(
223                &guide,
224                &guide_plan,
225                options.layout,
226                options
227                    .show_footer_rule
228                    .unwrap_or_else(|| default_show_footer_rule(options.layout)),
229            ),
230            OutputFormat::Guide,
231            &guide_plan.settings,
232        )
233    }
234}
235
236#[cfg(test)]
237pub(crate) fn render_guide_with_layout(
238    guide: &GuideView,
239    settings: &RenderSettings,
240    layout: HelpLayout,
241) -> String {
242    render_guide_with_layout_with_chrome(
243        guide,
244        settings,
245        layout,
246        default_show_footer_rule(layout),
247        None,
248    )
249}
250
251#[cfg(test)]
252pub(crate) fn render_guide_with_layout_with_chrome(
253    guide: &GuideView,
254    settings: &RenderSettings,
255    layout: HelpLayout,
256    show_footer_rule: bool,
257    title_prefix: Option<&str>,
258) -> String {
259    let output = guide.to_output_result();
260    render_structured_output_with_guide_options(
261        &output,
262        settings,
263        StructuredGuideRenderOptions {
264            source_guide: Some(guide),
265            layout,
266            title_prefix,
267            show_footer_rule: Some(show_footer_rule),
268        },
269    )
270}
271
272fn default_show_footer_rule(layout: HelpLayout) -> bool {
273    matches!(layout, HelpLayout::Full)
274}
275
276fn apply_title_prefix(view: &GuideView, title_prefix: Option<&str>) -> GuideView {
277    let Some(prefix) = title_prefix else {
278        return view.clone();
279    };
280    let mut updated = view.clone();
281    if let Some(first) = updated.sections.first_mut() {
282        first.title = format!("{prefix} · {}", first.title);
283    } else if !updated.usage.is_empty() {
284        updated.sections.insert(
285            0,
286            GuideSection {
287                title: format!("{prefix} · Usage"),
288                kind: GuideSectionKind::Usage,
289                paragraphs: updated.usage.clone(),
290                entries: Vec::new(),
291                data: None,
292            },
293        );
294        updated.usage.clear();
295    }
296    updated
297}
298
299#[cfg(test)]
300mod tests;