mz/
ui.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Terminal user interface utilities.
11
12use std::fmt;
13use std::io;
14use std::io::Write;
15use std::time::Duration;
16
17use indicatif::ProgressBar;
18use indicatif::ProgressStyle;
19use mz_ore::option::OptionExt;
20
21use serde::{Deserialize, Serialize};
22use serde_aux::serde_introspection::serde_introspect;
23use tabled::settings::Style;
24use tabled::{Table, Tabled};
25use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
26
27use crate::error::Error;
28
29/// Specifies an output format.
30#[derive(Debug, Clone, clap::ValueEnum)]
31pub enum OutputFormat {
32    /// Text output.
33    Text,
34    /// JSON output.
35    Json,
36    /// CSV output.
37    Csv,
38}
39
40/// Formats terminal output according to the configured [`OutputFormat`].
41#[derive(Clone)]
42pub struct OutputFormatter {
43    output_format: OutputFormat,
44    no_color: bool,
45}
46
47/// Ticks displayed for loading without using colors
48const TICKS: [&str; 9] = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷", ""];
49
50impl OutputFormatter {
51    /// Creates a new output formatter that uses the specified output format.
52    pub fn new(output_format: OutputFormat, no_color: bool) -> OutputFormatter {
53        OutputFormatter {
54            output_format,
55            no_color,
56        }
57    }
58
59    /// Prints a message with color
60    pub fn print_with_color(&self, message: &str, color: Color, stderr: bool) -> Result<(), Error> {
61        let mut stdeo = match stderr {
62            true => StandardStream::stderr(ColorChoice::Always),
63            false => StandardStream::stdout(ColorChoice::Always),
64        };
65        stdeo.set_color(ColorSpec::new().set_fg(Some(color)))?;
66        write!(&mut stdeo, "{}", message)?;
67
68        // Reset the std err/out to original setting.
69        let _ = stdeo.reset();
70
71        Ok(())
72    }
73
74    /// Outputs a prefix warning.
75    pub fn output_warning(&self, msg: &str) -> Result<(), Error> {
76        if self.no_color {
77            eprintln!("\n* Warning * {}", msg);
78        } else {
79            let _ = self.print_with_color("\n* Warning *", Color::Yellow, true)?;
80            eprintln!(" {}", msg);
81        }
82
83        Ok(())
84    }
85
86    /// Outputs a single value.
87    pub fn output_scalar(&self, scalar: Option<&str>) -> Result<(), Error> {
88        match self.output_format {
89            OutputFormat::Text => println!("{}", scalar.display_or("<unset>")),
90            OutputFormat::Json => serde_json::to_writer(io::stdout(), &scalar)?,
91            OutputFormat::Csv => {
92                let mut w = csv::Writer::from_writer(io::stdout());
93                w.write_record([scalar.unwrap_or("<unset>")])?;
94                w.flush()?;
95            }
96        }
97        Ok(())
98    }
99
100    /// Outputs a table.
101    ///
102    /// The provided rows must derive [`Deserialize`], [`Serialize`], and
103    /// [`Tabled`]. The `Serialize` implementation is used for CSV and JSON
104    /// output. The `Deserialize` implementation is used to determine column
105    /// names for CSV output when no rows are present. The `Tabled`
106    /// implementation is used for text output.
107    pub fn output_table<'a, I, R>(&self, rows: I) -> Result<(), Error>
108    where
109        I: IntoIterator<Item = R>,
110        R: Deserialize<'a> + Serialize + Tabled,
111    {
112        match self.output_format {
113            OutputFormat::Text => {
114                let table = Table::new(rows).with(Style::psql()).to_string();
115                println!("{table}");
116            }
117            OutputFormat::Json => {
118                let rows = rows.into_iter().collect::<Vec<_>>();
119                serde_json::to_writer(io::stdout(), &rows)?;
120            }
121            OutputFormat::Csv => {
122                let mut w = csv::WriterBuilder::new()
123                    .has_headers(false)
124                    .from_writer(io::stdout());
125                w.write_record(serde_introspect::<R>())?;
126                for row in rows {
127                    w.serialize(row)?;
128                }
129                w.flush()?;
130            }
131        }
132        Ok(())
133    }
134
135    /// Prints a loading spinner followed by a message, until finished.
136    pub fn loading_spinner(&self, message: &str) -> ProgressBar {
137        let progress_bar = ProgressBar::new_spinner();
138        progress_bar.enable_steady_tick(Duration::from_millis(120));
139
140        let tick_strings: Vec<&str> = match self.no_color {
141            true => TICKS.to_vec(),
142            false => TICKS.to_vec(),
143        };
144
145        progress_bar.set_style(
146            ProgressStyle::default_spinner()
147                .template("{spinner:1.green/green} {msg}")
148                .expect("template known to be valid")
149                // For more spinners check out the cli-spinners project:
150                // https://github.com/sindresorhus/cli-spinners/blob/master/spinners.json
151                .tick_strings(&tick_strings),
152        );
153
154        progress_bar.set_message(message.to_string());
155
156        progress_bar
157    }
158}
159
160/// An optional `str` that renders as `<unset>` when `None`.
161#[derive(Serialize, Deserialize)]
162#[serde(transparent)]
163pub struct OptionalStr<'a>(pub Option<&'a str>);
164
165impl fmt::Display for OptionalStr<'_> {
166    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
167        match &self.0 {
168            None => f.write_str("<unset>"),
169            Some(s) => s.fmt(f),
170        }
171    }
172}