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