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