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 an identifier such as a row, patch, or package id.
182    #[must_use]
183    pub fn id(self, value: impl Into<String>) -> Self {
184        self.span(Role::Id, value)
185    }
186
187    /// Append a value or metavar.
188    #[must_use]
189    pub fn value(self, value: impl Into<String>) -> Self {
190        self.span(Role::Value, value)
191    }
192
193    /// Append secondary text.
194    #[must_use]
195    pub fn muted(self, value: impl Into<String>) -> Self {
196        self.span(Role::Muted, value)
197    }
198
199    /// Append success text.
200    #[must_use]
201    pub fn success(self, value: impl Into<String>) -> Self {
202        self.span(Role::Success, value)
203    }
204
205    /// Append warning text.
206    #[must_use]
207    pub fn warning(self, value: impl Into<String>) -> Self {
208        self.span(Role::Warning, value)
209    }
210
211    /// Append error text.
212    #[must_use]
213    pub fn error(self, value: impl Into<String>) -> Self {
214        self.span(Role::Error, value)
215    }
216
217    /// Semantic spans in order.
218    #[must_use]
219    pub fn spans(&self) -> &[Span] {
220        &self.spans
221    }
222
223    /// Whether this text has no spans or visible characters.
224    #[must_use]
225    pub fn is_empty(&self) -> bool {
226        self.spans.iter().all(|span| span.value.is_empty())
227    }
228}
229
230impl From<&str> for Text {
231    fn from(value: &str) -> Self {
232        Self::plain(value)
233    }
234}
235
236impl From<String> for Text {
237    fn from(value: String) -> Self {
238        Self::plain(value)
239    }
240}
241
242/// One semantic text span.
243#[derive(Clone, Debug, Eq, PartialEq)]
244pub struct Span {
245    role: Role,
246    value: String,
247}
248
249impl Span {
250    /// Semantic role.
251    #[must_use]
252    pub const fn role(&self) -> Role {
253        self.role
254    }
255
256    /// Text content.
257    #[must_use]
258    pub fn value(&self) -> &str {
259        &self.value
260    }
261}
262
263/// Meaning carried by a text span. A renderer chooses the visual style.
264#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
265#[non_exhaustive]
266pub enum Role {
267    /// Unstyled content.
268    #[default]
269    Plain,
270    /// Heading.
271    Heading,
272    /// Successful outcome.
273    Success,
274    /// Warning.
275    Warning,
276    /// Error.
277    Error,
278    /// Value or metavar.
279    Value,
280    /// Secondary information.
281    Muted,
282    /// Flag, command, field name, or other operator token.
283    Token,
284    /// Identifier of a row, patch, package, or other domain record.
285    Id,
286}
287
288/// Key/value rows.
289#[derive(Clone, Debug, Default, Eq, PartialEq)]
290pub struct Fields {
291    rows: Vec<(Text, Text)>,
292}
293
294impl Fields {
295    /// Start empty fields.
296    #[must_use]
297    pub const fn new() -> Self {
298        Self { rows: Vec::new() }
299    }
300
301    /// Append one field. Labels receive token semantics automatically.
302    #[must_use]
303    pub fn row(mut self, label: impl Into<String>, value: impl Into<Text>) -> Self {
304        self.rows.push((Text::new().token(label), value.into()));
305        self
306    }
307
308    /// Append one field with an explicitly composed label.
309    #[must_use]
310    pub fn text_row(mut self, label: impl Into<Text>, value: impl Into<Text>) -> Self {
311        self.rows.push((label.into(), value.into()));
312        self
313    }
314
315    /// Rows in display order.
316    #[must_use]
317    pub fn rows(&self) -> &[(Text, Text)] {
318        &self.rows
319    }
320
321    /// Whether no rows exist.
322    #[must_use]
323    pub fn is_empty(&self) -> bool {
324        self.rows.is_empty()
325    }
326}
327
328/// A semantic table independent of its rendering engine.
329#[derive(Clone, Debug, Default, Eq, PartialEq)]
330pub struct Table {
331    headers: Vec<Text>,
332    rows: Vec<Vec<Text>>,
333    token_column: Option<usize>,
334    id_column: Option<usize>,
335    stacked_below: Option<Stacked>,
336}
337
338impl Table {
339    /// Start a headerless table.
340    #[must_use]
341    pub fn plain() -> Self {
342        Self::default()
343    }
344
345    /// Start a table with headers.
346    #[must_use]
347    pub fn new(headers: impl IntoIterator<Item = impl Into<Text>>) -> Self {
348        Self {
349            headers: headers.into_iter().map(Into::into).collect(),
350            ..Self::default()
351        }
352    }
353
354    /// Style one column as operator tokens.
355    #[must_use]
356    pub const fn token_column(mut self, index: usize) -> Self {
357        self.token_column = Some(index);
358        self
359    }
360
361    /// Style one column as identifiers.
362    #[must_use]
363    pub const fn id_column(mut self, index: usize) -> Self {
364        self.id_column = Some(index);
365        self
366    }
367
368    /// Stack labels and descriptions when the available width is below `width`.
369    ///
370    /// `label_columns` controls how many leading cells form the label. The
371    /// remaining cells form the indented description.
372    #[must_use]
373    pub const fn stacked_below(mut self, width: u16, label_columns: usize) -> Self {
374        self.stacked_below = Some(Stacked {
375            width,
376            label_columns,
377        });
378        self
379    }
380
381    /// Append one row.
382    #[must_use]
383    pub fn row(mut self, cells: impl IntoIterator<Item = impl Into<Text>>) -> Self {
384        self.rows.push(cells.into_iter().map(Into::into).collect());
385        self
386    }
387
388    /// Headers in order.
389    #[must_use]
390    pub fn headers(&self) -> &[Text] {
391        &self.headers
392    }
393
394    /// Rows in order.
395    #[must_use]
396    pub fn rows(&self) -> &[Vec<Text>] {
397        &self.rows
398    }
399
400    /// Column that carries token semantics.
401    #[must_use]
402    pub const fn token_column_index(&self) -> Option<usize> {
403        self.token_column
404    }
405
406    /// Column that carries identifier semantics.
407    #[must_use]
408    pub const fn id_column_index(&self) -> Option<usize> {
409        self.id_column
410    }
411
412    /// Narrow-layout policy.
413    #[must_use]
414    pub const fn stacked(&self) -> Option<Stacked> {
415        self.stacked_below
416    }
417
418    /// Whether no rows exist.
419    #[must_use]
420    pub fn is_empty(&self) -> bool {
421        self.rows.is_empty()
422    }
423}
424
425/// Narrow table layout policy.
426#[derive(Clone, Copy, Debug, Eq, PartialEq)]
427pub struct Stacked {
428    width: u16,
429    label_columns: usize,
430}
431
432impl Stacked {
433    /// Width below which rows stack.
434    #[must_use]
435    pub const fn width(self) -> u16 {
436        self.width
437    }
438
439    /// Number of leading label cells.
440    #[must_use]
441    pub const fn label_columns(self) -> usize {
442        self.label_columns
443    }
444}
445
446/// A titled nested document.
447#[derive(Clone, Debug, Eq, PartialEq)]
448pub struct Section {
449    title: Text,
450    body: Document,
451}
452
453impl Section {
454    /// Build a section.
455    #[must_use]
456    pub fn new(title: impl Into<Text>, body: Document) -> Self {
457        Self {
458            title: title.into(),
459            body,
460        }
461    }
462
463    /// Section title.
464    #[must_use]
465    pub const fn title(&self) -> &Text {
466        &self.title
467    }
468
469    /// Section body.
470    #[must_use]
471    pub const fn body(&self) -> &Document {
472        &self.body
473    }
474}
475
476/// A semantic notice.
477#[derive(Clone, Debug, Eq, PartialEq)]
478pub struct Notice {
479    level: NoticeLevel,
480    code: Option<String>,
481    message: Text,
482}
483
484impl Notice {
485    /// Build a notice.
486    #[must_use]
487    pub fn new(level: NoticeLevel, message: impl Into<Text>) -> Self {
488        Self {
489            level,
490            code: None,
491            message: message.into(),
492        }
493    }
494
495    /// Attach a stable notice code.
496    #[must_use]
497    pub fn code(mut self, code: impl Into<String>) -> Self {
498        self.code = Some(code.into());
499        self
500    }
501
502    /// Notice severity.
503    #[must_use]
504    pub const fn level(&self) -> NoticeLevel {
505        self.level
506    }
507
508    /// Optional stable code.
509    #[must_use]
510    pub fn code_value(&self) -> Option<&str> {
511        self.code.as_deref()
512    }
513
514    /// Human message.
515    #[must_use]
516    pub const fn message(&self) -> &Text {
517        &self.message
518    }
519}
520
521/// Notice severity.
522#[derive(Clone, Copy, Debug, Eq, PartialEq)]
523pub enum NoticeLevel {
524    /// Successful outcome.
525    Success,
526    /// Warning that does not fail the command.
527    Warning,
528    /// Failed outcome.
529    Error,
530}
531
532/// A horizontal divider.
533#[derive(Clone, Debug, Eq, PartialEq)]
534pub struct Rule {
535    title: Option<Text>,
536}
537
538impl Rule {
539    /// Optional title.
540    #[must_use]
541    pub const fn title(&self) -> Option<&Text> {
542        self.title.as_ref()
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::{Document, Fields, Notice, NoticeLevel, Role, Table, Text};
549
550    #[test]
551    fn fluent_document_preserves_semantics() {
552        let document = Document::new()
553            .heading("status")
554            .fields(Fields::new().row("pending", "2"))
555            .table(
556                Table::new(["id", "title"])
557                    .token_column(0)
558                    .row(["A-1", "Ship"]),
559            )
560            .notice(Notice::new(NoticeLevel::Warning, "stale"));
561
562        assert_eq!(document.blocks().len(), 4);
563        let token = Text::new().token("--force").then(" writes");
564        assert_eq!(token.spans()[0].role(), Role::Token);
565        assert_eq!(token.spans()[1].role(), Role::Plain);
566    }
567}