Skip to main content

ctl_core/
view.rs

1//! Pretty, colorless, and JSON emission from one typed model.
2
3use std::io::{self, Write};
4use std::process::ExitCode;
5
6use anstream::AutoStream;
7use serde::Serialize;
8
9use crate::color::ColorMode;
10use crate::document::{Document, Text};
11use crate::format::OutputFormat;
12use crate::model::{Envelope, ErrorBody};
13use crate::render::RenderOptions;
14
15/// A serializable domain model with one semantic human presentation.
16pub trait Present: Serialize {
17    /// Build the human document. JSON serializes `self` directly.
18    fn present(&self) -> Document;
19
20    /// Whether this value represents success or failure.
21    fn message_kind(&self) -> MessageKind {
22        MessageKind::Success
23    }
24
25    /// Process exit code. Typed protocols can distinguish usage errors from
26    /// operational failures without changing their presentation stream.
27    fn exit_code(&self) -> u8 {
28        self.message_kind().default_exit_code()
29    }
30}
31
32/// Human stream and exit semantics for a presented model.
33#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
34pub enum MessageKind {
35    /// Successful command result.
36    #[default]
37    Success,
38    /// Failed command result.
39    Error,
40}
41
42impl MessageKind {
43    /// Default process exit code.
44    #[must_use]
45    pub const fn default_exit_code(self) -> u8 {
46        match self {
47            Self::Success => 0,
48            Self::Error => 1,
49        }
50    }
51}
52
53/// Destination selected by a view.
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub enum Stream {
56    /// No output, used for quiet successful pretty output.
57    None,
58    /// Standard output.
59    Stdout,
60    /// Standard error.
61    Stderr,
62}
63
64/// Rendered bytes plus their destination and exit semantics.
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct Captured {
67    stream: Stream,
68    content: String,
69    exit_code: u8,
70}
71
72impl Captured {
73    /// Destination stream.
74    #[must_use]
75    pub const fn stream(&self) -> Stream {
76        self.stream
77    }
78
79    /// Rendered bytes.
80    #[must_use]
81    pub fn bytes(&self) -> &[u8] {
82        self.content.as_bytes()
83    }
84
85    /// UTF-8 rendered content.
86    #[must_use]
87    pub fn text(&self) -> &str {
88        &self.content
89    }
90
91    /// Process exit code.
92    #[must_use]
93    pub fn exit_code(&self) -> ExitCode {
94        ExitCode::from(self.exit_code)
95    }
96}
97
98/// How to present a model. JSON never contains ANSI.
99#[derive(Clone, Copy, Debug, Eq, PartialEq)]
100pub struct View {
101    /// Pretty or JSON.
102    pub format: OutputFormat,
103    /// ANSI policy for pretty output.
104    pub color: ColorMode,
105    /// Suppress successful pretty output.
106    pub quiet: bool,
107    width: Option<u16>,
108}
109
110impl View {
111    /// Build a view that prints successes.
112    #[must_use]
113    pub const fn new(format: OutputFormat, color: ColorMode) -> Self {
114        Self {
115            format,
116            color,
117            quiet: false,
118            width: None,
119        }
120    }
121
122    /// Suppress successful pretty output when `quiet` is set.
123    #[must_use]
124    pub const fn quiet(mut self, quiet: bool) -> Self {
125        self.quiet = quiet;
126        self
127    }
128
129    /// Force an explicit presentation width.
130    #[must_use]
131    pub const fn width(mut self, width: u16) -> Self {
132        self.width = Some(width);
133        self
134    }
135
136    /// Render without writing. Tests and alternate transports use this path.
137    pub fn capture(self, value: &impl Present) -> io::Result<Captured> {
138        let kind = value.message_kind();
139        let exit_code = value.exit_code();
140        if self.format.is_json() {
141            let mut content = serde_json::to_string(value)?;
142            content.push('\n');
143            return Ok(Captured {
144                stream: Stream::Stdout,
145                content,
146                exit_code,
147            });
148        }
149        if self.quiet && kind == MessageKind::Success {
150            return Ok(Captured {
151                stream: Stream::None,
152                content: String::new(),
153                exit_code,
154            });
155        }
156        let options = self.width.map_or_else(
157            || RenderOptions::new(self.color),
158            |width| RenderOptions::new(self.color).width(width),
159        );
160        let content = value.present().render(options);
161        Ok(Captured {
162            stream: match kind {
163                MessageKind::Success => Stream::Stdout,
164                MessageKind::Error => Stream::Stderr,
165            },
166            content,
167            exit_code,
168        })
169    }
170
171    /// Render and write one typed model. Returns its process exit semantics.
172    pub fn show(self, value: &impl Present) -> io::Result<ExitCode> {
173        let captured = self.capture(value)?;
174        match captured.stream() {
175            Stream::None => {}
176            Stream::Stdout => write_stdout(captured.bytes(), self.color)?,
177            Stream::Stderr => write_stderr(captured.bytes(), self.color)?,
178        }
179        Ok(captured.exit_code())
180    }
181
182    /// Emit `{bin}: {message}` or a JSON error envelope.
183    pub fn emit_err(self, bin: &str, message: &str) -> io::Result<ExitCode> {
184        if self.format.is_json() {
185            emit_json(&Envelope::<()>::err(ErrorBody::new(bin, message)))?;
186            return Ok(ExitCode::FAILURE);
187        }
188        let options = self.width.map_or_else(
189            || RenderOptions::new(self.color),
190            |width| RenderOptions::new(self.color).width(width),
191        );
192        let document =
193            Document::new().paragraph(Text::new().error(bin).then(": ").then(message.to_owned()));
194        write_stderr(document.render(options).as_bytes(), self.color)?;
195        Ok(ExitCode::FAILURE)
196    }
197}
198
199/// Write `value` as one JSON line to stdout. No ANSI.
200pub(crate) fn emit_json<T: Serialize>(value: &T) -> io::Result<()> {
201    let stdout = io::stdout();
202    let mut lock = stdout.lock();
203    serde_json::to_writer(&mut lock, value)?;
204    lock.write_all(b"\n")?;
205    lock.flush()
206}
207
208/// Write raw bytes to stdout with `color`.
209pub(crate) fn write_stdout(bytes: &[u8], color: ColorMode) -> io::Result<()> {
210    let mut stream = AutoStream::new(io::stdout().lock(), color.choice());
211    stream.write_all(bytes)?;
212    stream.flush()
213}
214
215/// Write raw bytes to stderr with `color`.
216pub(crate) fn write_stderr(bytes: &[u8], color: ColorMode) -> io::Result<()> {
217    let mut stream = AutoStream::new(io::stderr().lock(), color.choice());
218    stream.write_all(bytes)?;
219    stream.flush()
220}