Skip to main content

scriba/output/
content.rs

1use serde::Serialize;
2use serde_json::Value;
3use std::collections::BTreeMap;
4
5/// Structured output container with blocks and metadata.
6///
7/// Build up content using builder methods (`.heading()`, `.paragraph()`, etc.), then
8/// render with `Ui::render()` or `Ui::print()` in your preferred format.
9///
10/// # Examples
11///
12/// ```
13/// use scriba::{Output, StatusKind};
14///
15/// let output = Output::new()
16///     .title("Report")
17///     .heading(1, "Summary")
18///     .paragraph("All systems operational")
19///     .status(StatusKind::Ok, "Complete");
20/// ```
21#[derive(Debug, Clone, Default, Serialize)]
22pub struct Output {
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub title: Option<String>,
25
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub subtitle: Option<String>,
28
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub blocks: Vec<Block>,
31
32    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
33    pub data: BTreeMap<String, Value>,
34
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub plain: Option<Value>,
37
38    #[serde(default, skip_serializing_if = "Vec::is_empty")]
39    pub jsonl_records: Vec<Value>,
40}
41
42impl Output {
43    /// Create a new empty output.
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Set the title (rendered as # in Markdown, underlined in Text).
49    pub fn title(mut self, value: impl Into<String>) -> Self {
50        self.title = Some(value.into());
51        self
52    }
53
54    /// Set the subtitle (rendered as italics in Markdown).
55    pub fn subtitle(mut self, value: impl Into<String>) -> Self {
56        self.subtitle = Some(value.into());
57        self
58    }
59
60    /// Add structured data (key-value pair).
61    ///
62    /// Data is serialized and included in JSON/JSONL formats.
63    pub fn data(mut self, key: impl Into<String>, value: impl Serialize) -> Self {
64        let value = serde_json::to_value(value).unwrap_or(Value::Null);
65        self.data.insert(key.into(), value);
66        self
67    }
68
69    /// Set a plain scalar value for rendering.
70    ///
71    /// Used with `Format::Plain` for simple output (string, number, boolean).
72    pub fn plain(mut self, value: impl Serialize) -> Self {
73        self.plain = Some(serde_json::to_value(value).unwrap_or(Value::Null));
74        self
75    }
76
77    /// Add a JSONL record.
78    ///
79    /// Multiple records are rendered as newline-delimited JSON when using `Format::Jsonl`.
80    pub fn jsonl_record(mut self, value: impl Serialize) -> Self {
81        self.jsonl_records
82            .push(serde_json::to_value(value).unwrap_or(Value::Null));
83        self
84    }
85
86    /// Add a heading block.
87    ///
88    /// - `level`: 1-6 (levels 1-2 typically shown in most formats)
89    /// - `text`: Heading content
90    pub fn heading(mut self, level: u8, text: impl Into<String>) -> Self {
91        self.blocks.push(Block::Heading {
92            level,
93            text: text.into(),
94        });
95        self
96    }
97
98    /// Add a paragraph block.
99    pub fn paragraph(mut self, text: impl Into<String>) -> Self {
100        self.blocks.push(Block::Paragraph { text: text.into() });
101        self
102    }
103
104    /// Add a single line of text.
105    pub fn line(mut self, text: impl Into<String>) -> Self {
106        self.blocks.push(Block::Line { text: text.into() });
107        self
108    }
109
110    /// Add a visual separator.
111    pub fn separator(mut self) -> Self {
112        self.blocks.push(Block::Separator);
113        self
114    }
115
116    /// Add a list (ordered or unordered).
117    ///
118    /// - `ordered`: `true` for numbered list, `false` for bullet points
119    pub fn list(mut self, ordered: bool, items: Vec<String>) -> Self {
120        self.blocks.push(Block::List { ordered, items });
121        self
122    }
123
124    /// Add a code block.
125    ///
126    /// - `language`: Optional language hint for syntax highlighting (e.g., "rust", "bash")
127    /// - `code`: Source code content
128    pub fn code(mut self, language: Option<String>, code: impl Into<String>) -> Self {
129        self.blocks.push(Block::Code {
130            language,
131            code: code.into(),
132        });
133        self
134    }
135
136    /// Add a data table.
137    pub fn table(mut self, title: Option<String>, table: Table) -> Self {
138        self.blocks.push(Block::Table { title, table });
139        self
140    }
141
142    /// Add a JSON data block.
143    pub fn json(mut self, value: impl Serialize) -> Self {
144        self.blocks.push(Block::Json {
145            value: serde_json::to_value(value).unwrap_or(Value::Null),
146        });
147        self
148    }
149
150    /// Add or append a key-value pair.
151    ///
152    /// Consecutive calls group into a single block.
153    pub fn key_value(mut self, key: impl Into<String>, value: impl ToString) -> Self {
154        let entry = KeyValueEntry {
155            key: key.into(),
156            value: value.to_string(),
157        };
158
159        match self.blocks.last_mut() {
160            Some(Block::KeyValue { entries }) => entries.push(entry),
161            _ => self.blocks.push(Block::KeyValue {
162                entries: vec![entry],
163            }),
164        }
165
166        self
167    }
168
169    /// Add or append a definition (term/description pair).
170    ///
171    /// Consecutive calls group into a single definition list.
172    pub fn definition(mut self, term: impl Into<String>, description: impl Into<String>) -> Self {
173        let entry = DefinitionEntry {
174            term: term.into(),
175            description: description.into(),
176        };
177
178        match self.blocks.last_mut() {
179            Some(Block::DefinitionList { entries }) => entries.push(entry),
180            _ => self.blocks.push(Block::DefinitionList {
181                entries: vec![entry],
182            }),
183        }
184
185        self
186    }
187
188    /// Add a status block (for success, warning, error messages).
189    pub fn status(mut self, kind: StatusKind, text: impl Into<String>) -> Self {
190        self.blocks.push(Block::Status {
191            kind,
192            text: text.into(),
193        });
194        self
195    }
196
197    /// Add a section with a heading and code block.
198    ///
199    /// Convenience method that adds both a level-2 heading and code block.
200    pub fn section(
201        mut self,
202        title: impl Into<String>,
203        content: impl Into<String>,
204        language: impl Into<Option<String>>,
205    ) -> Self {
206        self.blocks.push(Block::Heading {
207            level: 2,
208            text: title.into(),
209        });
210
211        self.blocks.push(Block::Code {
212            language: language.into(),
213            code: content.into(),
214        });
215
216        self
217    }
218
219    /// Add a styled text block (with bold, italic, underline, etc.).
220    ///
221    /// Renders with ANSI codes in Text format and Markdown syntax in Markdown format.
222    pub fn styled_paragraph(mut self, styled: crate::output::style::Styled) -> Self {
223        self.blocks.push(Block::StyledText {
224            text: styled.text,
225            style: styled.style,
226        });
227        self
228    }
229
230    /// Add a styled heading (with bold, italic, underline, etc.).
231    ///
232    /// The heading level is rendered normally; style is applied to the heading text.
233    pub fn styled_heading(mut self, level: u8, styled: crate::output::style::Styled) -> Self {
234        self.blocks.push(Block::Heading {
235            level,
236            text: styled.text,
237        });
238        self
239    }
240
241    /// Create output from a serializable value.
242    ///
243    /// Converts objects to key-value data; other values are stored as data["value"].
244    pub fn from_serializable(value: impl Serialize) -> Self {
245        let json = serde_json::to_value(value).unwrap_or(Value::Null);
246
247        match json {
248            Value::Object(map) => Self {
249                title: None,
250                subtitle: None,
251                blocks: Vec::new(),
252                data: map.into_iter().collect(),
253                plain: None,
254                jsonl_records: Vec::new(),
255            },
256            other => Self::new().data("value", other),
257        }
258    }
259}
260
261/// A content block within an output.
262///
263/// Blocks are rendered according to the configured format (Markdown, JSON, etc.).
264#[derive(Debug, Clone, Serialize)]
265#[serde(tag = "type", rename_all = "snake_case")]
266pub enum Block {
267    /// Heading at the specified level (1-6).
268    Heading { level: u8, text: String },
269    /// A paragraph of text.
270    Paragraph { text: String },
271    /// A single line of text.
272    Line { text: String },
273    /// A visual separator/divider.
274    Separator,
275    /// An ordered or unordered list.
276    List { ordered: bool, items: Vec<String> },
277    /// A code block with optional language hint.
278    Code {
279        language: Option<String>,
280        code: String,
281    },
282    /// A structured data table.
283    Table { title: Option<String>, table: Table },
284    /// Raw JSON data.
285    Json { value: Value },
286    /// Key-value pairs.
287    KeyValue { entries: Vec<KeyValueEntry> },
288    /// Term definitions.
289    DefinitionList { entries: Vec<DefinitionEntry> },
290    /// Status indicator (ok, warning, error).
291    Status { kind: StatusKind, text: String },
292    /// Styled text block.
293    StyledText {
294        text: String,
295        style: crate::output::style::TextStyle,
296    },
297}
298
299/// Layout style for rendering tables.
300///
301/// Controls spacing, borders, and overall presentation of tabular data.
302///
303/// # Examples
304///
305/// ```
306/// use scriba::TableLayout;
307///
308/// let full = TableLayout::Full;     // bordered, full width
309/// let compact = TableLayout::Compact; // minimal spacing, no borders
310/// let stacked = TableLayout::Stacked;  // key-value per row, narrow-terminal friendly
311/// ```
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
313pub enum TableLayout {
314    /// Full width with borders and padding (default).
315    ///
316    /// Best for normal terminal widths, clearly delineated rows and columns.
317    #[default]
318    Full,
319    /// Minimal spacing and no borders, compact display.
320    ///
321    /// Good for dense data or when space is limited.
322    Compact,
323    /// Stacked format: one row per line with key-value pairs.
324    ///
325    /// Excellent for narrow terminals, mobile output, or accessibility.
326    /// Each row is displayed as:
327    /// ```text
328    /// Name: value1
329    /// Desc: description1
330    /// ---
331    /// ```
332    Stacked,
333}
334
335impl TableLayout {
336    /// Returns `true` if the layout is `Full`.
337    pub fn is_full(self) -> bool {
338        matches!(self, Self::Full)
339    }
340
341    /// Returns `true` if the layout is `Compact`.
342    pub fn is_compact(self) -> bool {
343        matches!(self, Self::Compact)
344    }
345
346    /// Returns `true` if the layout is `Stacked`.
347    pub fn is_stacked(self) -> bool {
348        matches!(self, Self::Stacked)
349    }
350}
351
352/// A structured data table with headers and rows.
353///
354/// # Examples
355///
356/// ```
357/// use scriba::Table;
358///
359/// let table = Table::new(
360///     vec!["Name".into(), "Value".into()],
361///     vec![
362///         vec!["Option A".into(), "1".into()],
363///         vec!["Option B".into(), "2".into()],
364///     ],
365/// ).with_index();
366/// ```
367#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
368pub struct Table {
369    /// Column headers.
370    pub headers: Vec<String>,
371    /// Table rows.
372    pub rows: Vec<Vec<String>>,
373    #[serde(default)]
374    /// Whether to show row numbers.
375    pub show_index: bool,
376    #[serde(default = "default_index_header")]
377    /// Header label for index column (e.g., "#").
378    pub index_header: String,
379    #[serde(default = "default_table_layout")]
380    /// Layout style for rendering (Full, Compact, or Stacked).
381    pub layout: TableLayout,
382}
383
384fn default_table_layout() -> TableLayout {
385    TableLayout::Full
386}
387
388fn default_index_header() -> String {
389    "#".to_string()
390}
391
392impl Table {
393    /// Create a new table with headers and rows.
394    pub fn new(headers: Vec<String>, rows: Vec<Vec<String>>) -> Self {
395        Self {
396            headers,
397            rows,
398            show_index: false,
399            index_header: default_index_header(),
400            layout: default_table_layout(),
401        }
402    }
403
404    /// Enable row numbering.
405    pub fn with_index(mut self) -> Self {
406        self.show_index = true;
407        self
408    }
409
410    /// Set custom header for row number column.
411    pub fn with_index_header(mut self, value: impl Into<String>) -> Self {
412        self.show_index = true;
413        self.index_header = value.into();
414        self
415    }
416
417    /// Set the table layout.
418    pub fn with_layout(mut self, layout: TableLayout) -> Self {
419        self.layout = layout;
420        self
421    }
422
423    /// Set the table layout to Full (bordered, full width).
424    pub fn with_layout_full(mut self) -> Self {
425        self.layout = TableLayout::Full;
426        self
427    }
428
429    /// Set the table layout to Compact (minimal spacing, no borders).
430    pub fn with_layout_compact(mut self) -> Self {
431        self.layout = TableLayout::Compact;
432        self
433    }
434
435    /// Set the table layout to Stacked (key-value per row).
436    pub fn with_layout_stacked(mut self) -> Self {
437        self.layout = TableLayout::Stacked;
438        self
439    }
440
441    /// Create table from borrowed string slices.
442    pub fn from_slices(headers: &[&str], rows: &[Vec<String>]) -> Self {
443        Self {
444            headers: headers.iter().map(|s| (*s).to_string()).collect(),
445            rows: rows.to_vec(),
446            show_index: false,
447            index_header: default_index_header(),
448            layout: default_table_layout(),
449        }
450    }
451
452    /// Get a version of this table with row indices materialized.
453    pub fn materialized(&self) -> Self {
454        if !self.show_index {
455            return self.clone();
456        }
457
458        let mut headers = Vec::with_capacity(self.headers.len() + 1);
459        headers.push(self.index_header.clone());
460        headers.extend(self.headers.clone());
461
462        let rows = self
463            .rows
464            .iter()
465            .enumerate()
466            .map(|(idx, row)| {
467                let mut new_row = Vec::with_capacity(row.len() + 1);
468                new_row.push((idx + 1).to_string());
469                new_row.extend(row.clone());
470                new_row
471            })
472            .collect();
473
474        Self {
475            headers,
476            rows,
477            show_index: false,
478            index_header: self.index_header.clone(),
479            layout: self.layout,
480        }
481    }
482
483    /// Convert table to JSON value.
484    pub fn to_json_value(&self) -> Value {
485        serde_json::to_value(self.materialized()).unwrap_or(Value::Null)
486    }
487}
488
489/// A key-value entry for display.
490#[derive(Debug, Clone, Serialize)]
491pub struct KeyValueEntry {
492    /// The key.
493    pub key: String,
494    /// The value.
495    pub value: String,
496}
497
498impl KeyValueEntry {
499    /// Create a new key-value entry.
500    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
501        Self {
502            key: key.into(),
503            value: value.into(),
504        }
505    }
506}
507
508/// A term-definition pair for display in a definition list.
509///
510/// # Example
511///
512/// ```
513/// use scriba::Output;
514///
515/// let output = Output::new()
516///     .definition("JPEG", "A lossy image compression format");
517/// ```
518#[derive(Debug, Clone, Serialize)]
519pub struct DefinitionEntry {
520    /// The term being defined.
521    pub term: String,
522    /// The description or definition.
523    pub description: String,
524}
525
526/// Status indicator kind for status blocks.
527///
528/// Used to indicate success, warnings, errors, or informational messages.
529///
530/// # Example
531///
532/// ```
533/// use scriba::{Output, StatusKind};
534///
535/// let output = Output::new()
536///     .status(StatusKind::Ok, "Deployment complete")
537///     .status(StatusKind::Warning, "High resource usage");
538/// ```
539#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
540#[serde(rename_all = "snake_case")]
541pub enum StatusKind {
542    /// Informational status.
543    Info,
544    /// Success status (typically green).
545    Ok,
546    /// Warning status (typically yellow/orange).
547    Warning,
548    /// Error status (typically red).
549    Error,
550    /// Deprecated: use `Ok` instead.
551    #[deprecated(since = "0.2.0", note = "use `StatusKind::Ok` instead")]
552    Success,
553}