1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
// Copyright Materialize, Inc. and contributors. All rights reserved.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0.

//! Terminal user interface utilities.

use std::fmt;
use std::io;
use std::io::Write;
use std::time::Duration;

use indicatif::ProgressBar;
use indicatif::ProgressStyle;
use mz_ore::option::OptionExt;

use serde::{Deserialize, Serialize};
use serde_aux::serde_introspection::serde_introspect;
use tabled::{Style, Table, Tabled};
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};

use crate::error::Error;

/// Specifies an output format.
#[derive(Debug, Clone, clap::ValueEnum)]
pub enum OutputFormat {
    /// Text output.
    Text,
    /// JSON output.
    Json,
    /// CSV output.
    Csv,
}

/// Formats terminal output according to the configured [`OutputFormat`].
#[derive(Clone)]
pub struct OutputFormatter {
    output_format: OutputFormat,
    no_color: bool,
}

/// Ticks displayed for loading without using colors
const TICKS: [&str; 9] = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷", ""];

impl OutputFormatter {
    /// Creates a new output formatter that uses the specified output format.
    pub fn new(output_format: OutputFormat, no_color: bool) -> OutputFormatter {
        OutputFormatter {
            output_format,
            no_color,
        }
    }

    /// Prints a message with color
    pub fn print_with_color(&self, message: &str, color: Color, stderr: bool) -> Result<(), Error> {
        let mut stdeo = match stderr {
            true => StandardStream::stderr(ColorChoice::Always),
            false => StandardStream::stdout(ColorChoice::Always),
        };
        stdeo.set_color(ColorSpec::new().set_fg(Some(color)))?;
        write!(&mut stdeo, "{}", message)?;

        // Reset the std err/out to original setting.
        let _ = stdeo.reset();

        Ok(())
    }

    /// Outputs a prefix warning.
    pub fn output_warning(&self, msg: &str) -> Result<(), Error> {
        if self.no_color {
            eprintln!("\n* Warning * {}", msg);
        } else {
            let _ = self.print_with_color("\n* Warning *", Color::Yellow, true)?;
            eprintln!(" {}", msg);
        }

        Ok(())
    }

    /// Outputs a single value.
    pub fn output_scalar(&self, scalar: Option<&str>) -> Result<(), Error> {
        match self.output_format {
            OutputFormat::Text => println!("{}", scalar.display_or("<unset>")),
            OutputFormat::Json => serde_json::to_writer(io::stdout(), &scalar)?,
            OutputFormat::Csv => {
                let mut w = csv::Writer::from_writer(io::stdout());
                w.write_record([scalar.unwrap_or("<unset>")])?;
                w.flush()?;
            }
        }
        Ok(())
    }

    /// Outputs a table.
    ///
    /// The provided rows must derive [`Deserialize`], [`Serialize`], and
    /// [`Tabled`]. The `Serialize` implementation is used for CSV and JSON
    /// output. The `Deserialize` implementation is used to determine column
    /// names for CSV output when no rows are present. The `Tabled`
    /// implementation is used for text output.
    pub fn output_table<'a, I, R>(&self, rows: I) -> Result<(), Error>
    where
        I: IntoIterator<Item = R>,
        R: Deserialize<'a> + Serialize + Tabled,
    {
        match self.output_format {
            OutputFormat::Text => {
                let table = Table::new(rows).with(Style::psql()).to_string();
                println!("{table}");
            }
            OutputFormat::Json => {
                let rows = rows.into_iter().collect::<Vec<_>>();
                serde_json::to_writer(io::stdout(), &rows)?;
            }
            OutputFormat::Csv => {
                let mut w = csv::WriterBuilder::new()
                    .has_headers(false)
                    .from_writer(io::stdout());
                w.write_record(serde_introspect::<R>())?;
                for row in rows {
                    w.serialize(row)?;
                }
                w.flush()?;
            }
        }
        Ok(())
    }

    /// Prints a loading spinner followed by a message, until finished.
    pub fn loading_spinner(&self, message: &str) -> ProgressBar {
        let progress_bar = ProgressBar::new_spinner();
        progress_bar.enable_steady_tick(Duration::from_millis(120));

        let tick_strings: Vec<&str> = match self.no_color {
            true => TICKS.to_vec(),
            false => TICKS.to_vec(),
        };

        progress_bar.set_style(
            ProgressStyle::default_spinner()
                .template("{spinner:1.green/green} {msg}")
                .expect("template known to be valid")
                // For more spinners check out the cli-spinners project:
                // https://github.com/sindresorhus/cli-spinners/blob/master/spinners.json
                .tick_strings(&tick_strings),
        );

        progress_bar.set_message(message.to_string());

        progress_bar
    }
}

/// An optional `str` that renders as `<unset>` when `None`.
#[derive(Serialize, Deserialize)]
#[serde(transparent)]
pub struct OptionalStr<'a>(pub Option<&'a str>);

impl fmt::Display for OptionalStr<'_> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match &self.0 {
            None => f.write_str("<unset>"),
            Some(s) => s.fmt(f),
        }
    }
}