Skip to main content

ctl_core/
document.rs

1//! Semantic presentation document shared by every ctl CLI.
2//!
3//! Consumers describe information with these types. Terminal layout, borders,
4//! wrapping, color, and streams remain ctl-core implementation details.
5
6/// A composable human presentation.
7#[derive(Clone, Debug, Default, Eq, PartialEq)]
8pub struct Document {
9    blocks: Vec<Block>,
10}
11
12impl Document {
13    /// Start an empty document.
14    #[must_use]
15    pub const fn new() -> Self {
16        Self { blocks: Vec::new() }
17    }
18
19    /// Append one semantic block.
20    #[must_use]
21    pub fn block(mut self, block: impl Into<Block>) -> Self {
22        self.blocks.push(block.into());
23        self
24    }
25
26    /// Append a heading.
27    #[must_use]
28    pub fn heading(self, value: impl Into<Text>) -> Self {
29        self.block(Block::Heading(value.into()))
30    }
31
32    /// Append wrapped prose.
33    #[must_use]
34    pub fn paragraph(self, value: impl Into<Text>) -> Self {
35        self.block(Block::Paragraph(value.into()))
36    }
37
38    /// Append preformatted text without wrapping or semantic styling.
39    #[must_use]
40    pub fn verbatim(self, value: impl Into<String>) -> Self {
41        self.block(Block::Verbatim(value.into()))
42    }
43
44    /// Append key/value fields.
45    #[must_use]
46    pub fn fields(self, fields: Fields) -> Self {
47        self.block(fields)
48    }
49
50    /// Append a grid.
51    #[must_use]
52    pub fn table(self, table: Table) -> Self {
53        self.block(table)
54    }
55
56    /// Append a titled section.
57    #[must_use]
58    pub fn section(self, section: Section) -> Self {
59        self.block(section)
60    }
61
62    /// Append a semantic notice.
63    #[must_use]
64    pub fn notice(self, notice: Notice) -> Self {
65        self.block(notice)
66    }
67
68    /// Append a horizontal rule, optionally titled.
69    #[must_use]
70    pub fn rule(self, title: Option<Text>) -> Self {
71        self.block(Rule { title })
72    }
73
74    /// Whether this document has no blocks.
75    #[must_use]
76    pub fn is_empty(&self) -> bool {
77        self.blocks.is_empty()
78    }
79
80    /// Semantic blocks in document order.
81    #[must_use]
82    pub fn blocks(&self) -> &[Block] {
83        &self.blocks
84    }
85}
86
87/// One semantic presentation block.
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub enum Block {
90    /// Section-level heading.
91    Heading(Text),
92    /// Wrapped prose.
93    Paragraph(Text),
94    /// Preformatted text kept unwrapped inside the document newline contract.
95    /// Terminal controls, bidi controls, and Unicode line separators are
96    /// removed; other Unicode formatting survives.
97    Verbatim(String),
98    /// Key/value fields.
99    Fields(Fields),
100    /// Headered or headerless table.
101    Table(Table),
102    /// Heading plus nested document.
103    Section(Section),
104    /// Success, warning, or error notice.
105    Notice(Notice),
106    /// Horizontal divider.
107    Rule(Rule),
108}
109
110impl From<Fields> for Block {
111    fn from(value: Fields) -> Self {
112        Self::Fields(value)
113    }
114}
115
116impl From<Table> for Block {
117    fn from(value: Table) -> Self {
118        Self::Table(value)
119    }
120}
121
122impl From<Section> for Block {
123    fn from(value: Section) -> Self {
124        Self::Section(value)
125    }
126}
127
128impl From<Notice> for Block {
129    fn from(value: Notice) -> Self {
130        Self::Notice(value)
131    }
132}
133
134impl From<Rule> for Block {
135    fn from(value: Rule) -> Self {
136        Self::Rule(value)
137    }
138}
139
140/// Styled text assembled from semantic spans.
141#[derive(Clone, Debug, Default, Eq, PartialEq)]
142pub struct Text {
143    spans: Vec<Span>,
144}
145
146impl Text {
147    /// Start empty text.
148    #[must_use]
149    pub const fn new() -> Self {
150        Self { spans: Vec::new() }
151    }
152
153    /// Start plain text.
154    #[must_use]
155    pub fn plain(value: impl Into<String>) -> Self {
156        Self::new().span(Role::Plain, value)
157    }
158
159    /// Append one semantic span.
160    #[must_use]
161    pub fn span(mut self, role: Role, value: impl Into<String>) -> Self {
162        self.spans.push(Span {
163            role,
164            value: value.into(),
165        });
166        self
167    }
168
169    /// Append plain text.
170    #[must_use]
171    pub fn then(self, value: impl Into<String>) -> Self {
172        self.span(Role::Plain, value)
173    }
174
175    /// Append a semantic token such as a flag, command, or field name.
176    #[must_use]
177    pub fn token(self, value: impl Into<String>) -> Self {
178        self.span(Role::Token, value)
179    }
180
181    /// Append a value or metavar.
182    #[must_use]
183    pub fn value(self, value: impl Into<String>) -> Self {
184        self.span(Role::Value, value)
185    }
186
187    /// Append secondary text.
188    #[must_use]
189    pub fn muted(self, value: impl Into<String>) -> Self {
190        self.span(Role::Muted, value)
191    }
192
193    /// Append success text.
194    #[must_use]
195    pub fn success(self, value: impl Into<String>) -> Self {
196        self.span(Role::Success, value)
197    }
198
199    /// Append warning text.
200    #[must_use]
201    pub fn warning(self, value: impl Into<String>) -> Self {
202        self.span(Role::Warning, value)
203    }
204
205    /// Append error text.
206    #[must_use]
207    pub fn error(self, value: impl Into<String>) -> Self {
208        self.span(Role::Error, value)
209    }
210
211    /// Semantic spans in order.
212    #[must_use]
213    pub fn spans(&self) -> &[Span] {
214        &self.spans
215    }
216
217    /// Whether this text has no spans or visible characters.
218    #[must_use]
219    pub fn is_empty(&self) -> bool {
220        self.spans.iter().all(|span| span.value.is_empty())
221    }
222}
223
224impl From<&str> for Text {
225    fn from(value: &str) -> Self {
226        Self::plain(value)
227    }
228}
229
230impl From<String> for Text {
231    fn from(value: String) -> Self {
232        Self::plain(value)
233    }
234}
235
236/// One semantic text span.
237#[derive(Clone, Debug, Eq, PartialEq)]
238pub struct Span {
239    role: Role,
240    value: String,
241}
242
243impl Span {
244    /// Semantic role.
245    #[must_use]
246    pub const fn role(&self) -> Role {
247        self.role
248    }
249
250    /// Text content.
251    #[must_use]
252    pub fn value(&self) -> &str {
253        &self.value
254    }
255}
256
257/// Meaning carried by a text span. A renderer chooses the visual style.
258#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
259pub enum Role {
260    /// Unstyled content.
261    #[default]
262    Plain,
263    /// Heading.
264    Heading,
265    /// Successful outcome.
266    Success,
267    /// Warning.
268    Warning,
269    /// Error.
270    Error,
271    /// Value or metavar.
272    Value,
273    /// Secondary information.
274    Muted,
275    /// Flag, command, field name, or other operator token.
276    Token,
277}
278
279/// Key/value rows.
280#[derive(Clone, Debug, Default, Eq, PartialEq)]
281pub struct Fields {
282    rows: Vec<(Text, Text)>,
283}
284
285impl Fields {
286    /// Start empty fields.
287    #[must_use]
288    pub const fn new() -> Self {
289        Self { rows: Vec::new() }
290    }
291
292    /// Append one field. Labels receive token semantics automatically.
293    #[must_use]
294    pub fn row(mut self, label: impl Into<String>, value: impl Into<Text>) -> Self {
295        self.rows.push((Text::new().token(label), value.into()));
296        self
297    }
298
299    /// Append one field with an explicitly composed label.
300    #[must_use]
301    pub fn text_row(mut self, label: impl Into<Text>, value: impl Into<Text>) -> Self {
302        self.rows.push((label.into(), value.into()));
303        self
304    }
305
306    /// Rows in display order.
307    #[must_use]
308    pub fn rows(&self) -> &[(Text, Text)] {
309        &self.rows
310    }
311
312    /// Whether no rows exist.
313    #[must_use]
314    pub fn is_empty(&self) -> bool {
315        self.rows.is_empty()
316    }
317}
318
319/// A semantic table independent of its rendering engine.
320#[derive(Clone, Debug, Default, Eq, PartialEq)]
321pub struct Table {
322    headers: Vec<Text>,
323    rows: Vec<Vec<Text>>,
324    token_column: Option<usize>,
325    stacked_below: Option<Stacked>,
326}
327
328impl Table {
329    /// Start a headerless table.
330    #[must_use]
331    pub fn plain() -> Self {
332        Self::default()
333    }
334
335    /// Start a table with headers.
336    #[must_use]
337    pub fn new(headers: impl IntoIterator<Item = impl Into<Text>>) -> Self {
338        Self {
339            headers: headers.into_iter().map(Into::into).collect(),
340            ..Self::default()
341        }
342    }
343
344    /// Style one column as operator tokens.
345    #[must_use]
346    pub const fn token_column(mut self, index: usize) -> Self {
347        self.token_column = Some(index);
348        self
349    }
350
351    /// Stack labels and descriptions when the available width is below `width`.
352    ///
353    /// `label_columns` controls how many leading cells form the label. The
354    /// remaining cells form the indented description.
355    #[must_use]
356    pub const fn stacked_below(mut self, width: u16, label_columns: usize) -> Self {
357        self.stacked_below = Some(Stacked {
358            width,
359            label_columns,
360        });
361        self
362    }
363
364    /// Append one row.
365    #[must_use]
366    pub fn row(mut self, cells: impl IntoIterator<Item = impl Into<Text>>) -> Self {
367        self.rows.push(cells.into_iter().map(Into::into).collect());
368        self
369    }
370
371    /// Headers in order.
372    #[must_use]
373    pub fn headers(&self) -> &[Text] {
374        &self.headers
375    }
376
377    /// Rows in order.
378    #[must_use]
379    pub fn rows(&self) -> &[Vec<Text>] {
380        &self.rows
381    }
382
383    /// Column that carries token semantics.
384    #[must_use]
385    pub const fn token_column_index(&self) -> Option<usize> {
386        self.token_column
387    }
388
389    /// Narrow-layout policy.
390    #[must_use]
391    pub const fn stacked(&self) -> Option<Stacked> {
392        self.stacked_below
393    }
394
395    /// Whether no rows exist.
396    #[must_use]
397    pub fn is_empty(&self) -> bool {
398        self.rows.is_empty()
399    }
400}
401
402/// Narrow table layout policy.
403#[derive(Clone, Copy, Debug, Eq, PartialEq)]
404pub struct Stacked {
405    width: u16,
406    label_columns: usize,
407}
408
409impl Stacked {
410    /// Width below which rows stack.
411    #[must_use]
412    pub const fn width(self) -> u16 {
413        self.width
414    }
415
416    /// Number of leading label cells.
417    #[must_use]
418    pub const fn label_columns(self) -> usize {
419        self.label_columns
420    }
421}
422
423/// A titled nested document.
424#[derive(Clone, Debug, Eq, PartialEq)]
425pub struct Section {
426    title: Text,
427    body: Document,
428}
429
430impl Section {
431    /// Build a section.
432    #[must_use]
433    pub fn new(title: impl Into<Text>, body: Document) -> Self {
434        Self {
435            title: title.into(),
436            body,
437        }
438    }
439
440    /// Section title.
441    #[must_use]
442    pub const fn title(&self) -> &Text {
443        &self.title
444    }
445
446    /// Section body.
447    #[must_use]
448    pub const fn body(&self) -> &Document {
449        &self.body
450    }
451}
452
453/// A semantic notice.
454#[derive(Clone, Debug, Eq, PartialEq)]
455pub struct Notice {
456    level: NoticeLevel,
457    code: Option<String>,
458    message: Text,
459}
460
461impl Notice {
462    /// Build a notice.
463    #[must_use]
464    pub fn new(level: NoticeLevel, message: impl Into<Text>) -> Self {
465        Self {
466            level,
467            code: None,
468            message: message.into(),
469        }
470    }
471
472    /// Attach a stable notice code.
473    #[must_use]
474    pub fn code(mut self, code: impl Into<String>) -> Self {
475        self.code = Some(code.into());
476        self
477    }
478
479    /// Notice severity.
480    #[must_use]
481    pub const fn level(&self) -> NoticeLevel {
482        self.level
483    }
484
485    /// Optional stable code.
486    #[must_use]
487    pub fn code_value(&self) -> Option<&str> {
488        self.code.as_deref()
489    }
490
491    /// Human message.
492    #[must_use]
493    pub const fn message(&self) -> &Text {
494        &self.message
495    }
496}
497
498/// Notice severity.
499#[derive(Clone, Copy, Debug, Eq, PartialEq)]
500pub enum NoticeLevel {
501    /// Successful outcome.
502    Success,
503    /// Warning that does not fail the command.
504    Warning,
505    /// Failed outcome.
506    Error,
507}
508
509/// A horizontal divider.
510#[derive(Clone, Debug, Eq, PartialEq)]
511pub struct Rule {
512    title: Option<Text>,
513}
514
515impl Rule {
516    /// Optional title.
517    #[must_use]
518    pub const fn title(&self) -> Option<&Text> {
519        self.title.as_ref()
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::{Document, Fields, Notice, NoticeLevel, Role, Table, Text};
526
527    #[test]
528    fn fluent_document_preserves_semantics() {
529        let document = Document::new()
530            .heading("status")
531            .fields(Fields::new().row("pending", "2"))
532            .table(
533                Table::new(["id", "title"])
534                    .token_column(0)
535                    .row(["A-1", "Ship"]),
536            )
537            .notice(Notice::new(NoticeLevel::Warning, "stale"));
538
539        assert_eq!(document.blocks().len(), 4);
540        let token = Text::new().token("--force").then(" writes");
541        assert_eq!(token.spans()[0].role(), Role::Token);
542        assert_eq!(token.spans()[1].role(), Role::Plain);
543    }
544}