1use std::borrow::Cow;
2use std::io::{self, IsTerminal as _, Write};
3use std::sync::Mutex;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6use miette::IntoDiagnostic as _;
7use serde::Serialize;
8
9use crate::error::Result;
10
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, usage_rs::ValueEnum)]
13pub enum Format {
14 #[default]
16 Table,
17 Json,
19 Ndjson,
21}
22
23pub struct Output {
24 format: Format,
25 tty: bool,
26 closed: AtomicBool,
27 out: Mutex<Box<dyn Write + Send>>,
28}
29
30impl Output {
31 pub fn stdout(format: Format) -> Self {
32 Self::new(format, io::stdout().is_terminal(), Box::new(io::stdout()))
33 }
34
35 pub fn new(format: Format, tty: bool, out: Box<dyn Write + Send>) -> Self {
36 Self {
37 format,
38 tty,
39 closed: AtomicBool::new(false),
40 out: Mutex::new(out),
41 }
42 }
43
44 pub fn format(&self) -> Format {
45 self.format
46 }
47
48 pub fn is_terminal(&self) -> bool {
50 self.tty
51 }
52
53 pub fn is_closed(&self) -> bool {
55 self.closed.load(Ordering::Relaxed)
56 }
57
58 pub fn json<T: Serialize + ?Sized>(&self, value: &T) -> Result<()> {
60 if self.format == Format::Ndjson {
61 return self.ndjson(value);
62 }
63 let text = serde_json::to_string_pretty(value).into_diagnostic()?;
64 self.emit(&escape_controls(&text))
65 }
66
67 pub fn ndjson<T: Serialize + ?Sized>(&self, value: &T) -> Result<()> {
68 let text = serde_json::to_string(value).into_diagnostic()?;
69 self.emit(&escape_controls(&text))
70 }
71
72 pub fn raw_json(&self, text: &str) -> Result<()> {
74 self.emit(&escape_controls(&indent_json(text)))
75 }
76
77 pub fn table(&self, table: &Table) -> Result<()> {
78 if table.is_empty() {
79 return Ok(());
80 }
81 self.write(&table.render(self.tty))
82 }
83
84 pub fn write(&self, text: &str) -> Result<()> {
86 self.emit(&printable(text))
87 }
88
89 fn emit(&self, text: &str) -> Result<()> {
90 if self.is_closed() {
91 return Ok(());
92 }
93 let mut out = self
94 .out
95 .lock()
96 .unwrap_or_else(|poisoned| poisoned.into_inner());
97 let result = out
98 .write_all(text.as_bytes())
99 .and_then(|()| out.write_all(b"\n"))
100 .and_then(|()| out.flush());
101 match result {
102 Err(error) if error.kind() == io::ErrorKind::BrokenPipe => {
103 self.closed.store(true, Ordering::Relaxed);
104 Ok(())
105 }
106 other => other.into_diagnostic(),
107 }
108 }
109
110 pub fn bytes(&self, data: &[u8]) -> Result<()> {
112 if self.is_closed() {
113 return Ok(());
114 }
115 let mut out = self
116 .out
117 .lock()
118 .unwrap_or_else(|poisoned| poisoned.into_inner());
119 match out.write_all(data).and_then(|()| out.flush()) {
120 Err(error) if error.kind() == io::ErrorKind::BrokenPipe => {
121 self.closed.store(true, Ordering::Relaxed);
122 Ok(())
123 }
124 other => other.into_diagnostic(),
125 }
126 }
127}
128
129pub struct Table {
130 header: Vec<String>,
131 rows: Vec<Vec<String>>,
132}
133
134impl Table {
135 pub fn new<S: Into<String>>(header: impl IntoIterator<Item = S>) -> Self {
136 Self {
137 header: header.into_iter().map(Into::into).collect(),
138 rows: Vec::new(),
139 }
140 }
141
142 pub fn row<S: Into<String>>(&mut self, cells: impl IntoIterator<Item = S>) -> &mut Self {
143 self.rows.push(cells.into_iter().map(Into::into).collect());
144 self
145 }
146
147 pub fn is_empty(&self) -> bool {
148 self.rows.is_empty()
149 }
150
151 fn render(&self, tty: bool) -> String {
152 let mut table = comfy_table::Table::new();
153 table.load_style(comfy_table::presets::NOTHING);
154 if tty {
155 table.set_content_arrangement(comfy_table::ContentArrangement::Dynamic);
156 table.set_header(self.header.clone());
157 }
158 for row in &self.rows {
159 table.add_row(row.clone());
160 }
161 for column in table.column_iter_mut() {
162 column.set_padding((0, 2));
163 }
164 table
165 .to_string()
166 .lines()
167 .map(|line| line.trim_end().to_string())
168 .collect::<Vec<_>>()
169 .join("\n")
170 }
171}
172
173pub fn printable(text: &str) -> Cow<'_, str> {
175 if !text.chars().any(is_unsafe) {
176 return Cow::Borrowed(text);
177 }
178 let mut out = String::with_capacity(text.len());
179 let mut chars = text.chars().peekable();
180 while let Some(c) = chars.next() {
181 match c {
182 '\r' if chars.peek() == Some(&'\n') => {}
183 c if is_unsafe(c) => out.push('\u{fffd}'),
184 c => out.push(c),
185 }
186 }
187 Cow::Owned(out)
188}
189
190fn is_unsafe(c: char) -> bool {
191 c.is_control() && c != '\n' && c != '\t'
192}
193
194fn escape_controls(json: &str) -> Cow<'_, str> {
196 let raw = |c: char| c.is_control() && c != '\n';
197 if !json.chars().any(raw) {
198 return Cow::Borrowed(json);
199 }
200 let mut out = String::with_capacity(json.len() + 16);
201 for c in json.chars() {
202 if raw(c) {
203 out.push_str(&format!("\\u{:04x}", u32::from(c)));
204 } else {
205 out.push(c);
206 }
207 }
208 Cow::Owned(out)
209}
210
211fn indent_json(text: &str) -> String {
213 let mut out = String::with_capacity(text.len() * 2);
214 let mut depth = 0;
215 let mut in_string = false;
216 let mut escaped = false;
217 let mut chars = text.chars().peekable();
218 while let Some(c) = chars.next() {
219 if in_string {
220 out.push(c);
221 if escaped {
222 escaped = false;
223 } else if c == '\\' {
224 escaped = true;
225 } else if c == '"' {
226 in_string = false;
227 }
228 continue;
229 }
230 match c {
231 '"' => {
232 in_string = true;
233 out.push(c);
234 }
235 '{' | '[' => {
236 out.push(c);
237 while chars.next_if(|next| next.is_whitespace()).is_some() {}
238 if let Some(close) = chars.next_if(|next| matches!(next, '}' | ']')) {
239 out.push(close);
240 } else {
241 depth += 1;
242 new_line(&mut out, depth);
243 }
244 }
245 '}' | ']' => {
246 depth -= 1;
247 new_line(&mut out, depth);
248 out.push(c);
249 }
250 ',' => {
251 out.push(c);
252 new_line(&mut out, depth);
253 }
254 ':' => out.push_str(": "),
255 c if c.is_whitespace() => {}
256 c => out.push(c),
257 }
258 }
259 out
260}
261
262fn new_line(out: &mut String, depth: usize) {
263 out.push('\n');
264 out.extend(std::iter::repeat_n(" ", depth));
265}
266
267#[cfg(test)]
268mod tests {
269 use std::io;
270 use std::sync::{Arc, Mutex};
271
272 use super::*;
273
274 #[derive(Clone, Default)]
275 struct Buffer(Arc<Mutex<Vec<u8>>>);
276
277 impl Write for Buffer {
278 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
279 self.0.lock().unwrap().extend_from_slice(data);
280 Ok(data.len())
281 }
282 fn flush(&mut self) -> io::Result<()> {
283 Ok(())
284 }
285 }
286
287 impl Buffer {
288 fn text(&self) -> String {
289 String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
290 }
291 }
292
293 struct ClosedPipe;
294
295 impl Write for ClosedPipe {
296 fn write(&mut self, _: &[u8]) -> io::Result<usize> {
297 Err(io::Error::from(io::ErrorKind::BrokenPipe))
298 }
299 fn flush(&mut self) -> io::Result<()> {
300 Ok(())
301 }
302 }
303
304 fn sample() -> Table {
305 let mut table = Table::new(["ID", "SUMMARY"]);
306 table.row(["WEB-1", "Login fails"]).row(["WEB-12", "Typo"]);
307 table
308 }
309
310 #[test]
311 fn json_is_pretty_printed() {
312 let buffer = Buffer::default();
313 let output = Output::new(Format::Json, false, Box::new(buffer.clone()));
314 output.json(&serde_json::json!({"id": "WEB-1"})).unwrap();
315 assert_eq!(buffer.text(), "{\n \"id\": \"WEB-1\"\n}\n");
316 }
317
318 #[test]
319 fn ndjson_is_one_line_per_item() {
320 let buffer = Buffer::default();
321 let output = Output::new(Format::Ndjson, false, Box::new(buffer.clone()));
322 output.ndjson(&serde_json::json!({"id": "WEB-1"})).unwrap();
323 output.ndjson(&serde_json::json!({"id": "WEB-2"})).unwrap();
324 assert_eq!(buffer.text(), "{\"id\":\"WEB-1\"}\n{\"id\":\"WEB-2\"}\n");
325 }
326
327 #[test]
328 fn a_single_result_is_one_line_in_ndjson_mode() {
329 let buffer = Buffer::default();
330 let output = Output::new(Format::Ndjson, false, Box::new(buffer.clone()));
331 output
332 .json(&serde_json::json!({"operations": [{"kind": "command"}]}))
333 .unwrap();
334 assert_eq!(buffer.text(), "{\"operations\":[{\"kind\":\"command\"}]}\n");
335 }
336
337 #[test]
338 fn raw_json_is_indented_like_serde_without_reordering() {
339 let text = r#"{"z":1,"a":[],"o":{ },"s":"q\"{a, b: c}\\","n":[{"k":null},2.5e3,true]}"#;
340 let value: serde_json::Value = serde_json::from_str(text).unwrap();
341 let indented = indent_json(text);
342 assert_eq!(
343 serde_json::from_str::<serde_json::Value>(&indented).unwrap(),
344 value
345 );
346 assert_eq!(
347 indented,
348 "{\n \"z\": 1,\n \"a\": [],\n \"o\": {},\n \"s\": \"q\\\"{a, b: c}\\\\\",\n \"n\": [\n {\n \"k\": null\n },\n 2.5e3,\n true\n ]\n}"
349 );
350 }
351
352 #[test]
353 fn piped_tables_have_no_header() {
354 let buffer = Buffer::default();
355 Output::new(Format::Table, false, Box::new(buffer.clone()))
356 .table(&sample())
357 .unwrap();
358 assert_eq!(buffer.text(), "WEB-1 Login fails\nWEB-12 Typo\n");
359 }
360
361 #[test]
362 fn terminal_tables_have_a_header() {
363 let buffer = Buffer::default();
364 Output::new(Format::Table, true, Box::new(buffer.clone()))
365 .table(&sample())
366 .unwrap();
367 assert!(
368 buffer.text().starts_with("ID SUMMARY"),
369 "{}",
370 buffer.text()
371 );
372 }
373
374 #[test]
375 fn printable_replaces_terminal_control_characters() {
376 assert_eq!(
377 printable("a\u{1b}[2Jb\u{7}c\u{9b}d\u{7f}e"),
378 "a\u{fffd}[2Jb\u{fffd}c\u{fffd}d\u{fffd}e"
379 );
380 assert_eq!(
381 printable("one\r\ntwo\tthree\rfour"),
382 "one\ntwo\tthree\u{fffd}four"
383 );
384 assert!(matches!(printable("plain ā\n"), Cow::Borrowed(_)));
385 }
386
387 #[test]
388 fn text_output_is_printable() {
389 let buffer = Buffer::default();
390 let output = Output::new(Format::Table, true, Box::new(buffer.clone()));
391 output.write("a\u{1b}]0;title\u{7}b").unwrap();
392 assert_eq!(buffer.text(), "a\u{fffd}]0;title\u{fffd}b\n");
393 }
394
395 #[test]
396 fn json_output_escapes_control_characters() {
397 let buffer = Buffer::default();
398 let output = Output::new(Format::Ndjson, false, Box::new(buffer.clone()));
399 output
400 .json(&serde_json::json!({"s": "a\u{9b}b\u{7f}"}))
401 .unwrap();
402 output.raw_json("{\"s\":\"c\u{1b}d\"}").unwrap();
403 assert_eq!(
404 buffer.text(),
405 "{\"s\":\"a\\u009bb\\u007f\"}\n{\n \"s\": \"c\\u001bd\"\n}\n"
406 );
407 }
408
409 #[test]
410 fn a_closed_pipe_is_not_an_error_and_stops_output() {
411 let output = Output::new(Format::Ndjson, false, Box::new(ClosedPipe));
412 output.write("first").unwrap();
413 assert!(output.is_closed());
414 output.write("second").unwrap();
415 }
416}