Skip to main content

drizzle_cli/
output.rs

1//! Terminal styling helpers, so every command formats output the same way
2//! (in the style of drizzle-kit).
3//!
4//! Each helper returns the text wrapped in ANSI color codes (via the
5//! `colored` crate, which honors `NO_COLOR`).
6
7use colored::Colorize;
8
9/// Bright cyan, for section headings.
10#[must_use]
11pub fn heading(text: &str) -> String {
12    format!("{}", text.bright_cyan())
13}
14
15/// Bright blue, for field labels.
16#[must_use]
17pub fn label(text: &str) -> String {
18    format!("{}", text.bright_blue())
19}
20
21/// Dim (bright black), for secondary text.
22#[must_use]
23pub fn muted(text: &str) -> String {
24    format!("{}", text.bright_black())
25}
26
27/// Bright green, for success messages.
28#[must_use]
29pub fn success(text: &str) -> String {
30    format!("{}", text.bright_green())
31}
32
33/// Yellow, for warnings.
34#[must_use]
35pub fn warning(text: &str) -> String {
36    format!("{}", text.yellow())
37}
38
39/// Red, for errors.
40#[must_use]
41pub fn error(text: &str) -> String {
42    format!("{}", text.red())
43}
44
45/// Prefixes `text` with a bold blue `Info:`.
46#[must_use]
47pub fn info(text: &str) -> String {
48    format!("{} {}", "Info:".bright_blue().bold(), text)
49}
50
51/// Formats `[Warning] text` with a yellow tag.
52#[must_use]
53pub fn warn_line(text: &str) -> String {
54    format!("[{}] {}", "Warning".yellow(), text)
55}
56
57/// Prefixes `text` with a bold red `Error`; used for the final error the CLI prints.
58#[must_use]
59pub fn err_line(text: &str) -> String {
60    format!("{} {}", "Error".red().bold(), text)
61}
62
63/// A red ` Invalid input ` badge followed by `text` in red.
64#[must_use]
65pub fn banner_invalid_input(text: &str) -> String {
66    format!("{} {}", " Invalid input ".white().on_red(), text.red())
67}
68
69/// A grey ` Warning ` badge followed by `text`.
70#[must_use]
71pub fn banner_warning(text: &str) -> String {
72    format!("{} {}", " Warning ".white().on_bright_black(), text)
73}
74
75/// A red ` Error ` badge followed by `text`.
76#[must_use]
77pub fn banner_error(text: &str) -> String {
78    format!("{} {}", " Error ".white().on_red().bold(), text)
79}
80
81/// A grey ` Suggestion ` badge followed by `text`.
82#[must_use]
83pub fn banner_suggestion(text: &str) -> String {
84    format!("{} {}", " Suggestion ".white().on_bright_black(), text)
85}
86
87/// Green `OK`, for check results.
88#[must_use]
89pub fn status_ok() -> String {
90    format!("{}", "OK".green())
91}
92
93/// Red `ERROR`, for check results.
94#[must_use]
95pub fn status_error() -> String {
96    format!("{}", "ERROR".red())
97}
98
99/// `text` in yellow, for check results.
100#[must_use]
101pub fn status_warning(text: &str) -> String {
102    format!("{}", text.yellow())
103}
104
105/// Hides the secrets in a connection URL: the password of `user:password@`
106/// and the value of `authToken`, `password`, `token` and similar query
107/// parameters.
108///
109/// # Examples
110///
111/// ```rust
112/// use drizzle_cli::output::mask_url;
113///
114/// assert_eq!(
115///     mask_url("postgres://user:secret@localhost:5432/db?sslmode=require"),
116///     "postgres://user:****@localhost:5432/db?sslmode=require"
117/// );
118/// assert_eq!(
119///     mask_url("libsql://app.turso.io?authToken=abc"),
120///     "libsql://app.turso.io?authToken=****"
121/// );
122/// ```
123#[must_use]
124pub fn mask_url(url: &str) -> String {
125    let (base, query) = match url.split_once('?') {
126        Some((base, query)) => (base, Some(query)),
127        None => (url, None),
128    };
129
130    let mut masked = String::with_capacity(url.len());
131    let scheme_end = base.find("://").map_or(0, |position| position + 3);
132    match base
133        .rfind('@')
134        .filter(|&at| at > scheme_end)
135        .and_then(|at| {
136            base[scheme_end..at]
137                .find(':')
138                .map(|colon| (scheme_end + colon, at))
139        }) {
140        Some((colon, at)) => {
141            masked.push_str(&base[..=colon]);
142            masked.push_str("****");
143            masked.push_str(&base[at..]);
144        }
145        None => masked.push_str(base),
146    }
147
148    if let Some(query) = query {
149        masked.push('?');
150        for (index, pair) in query.split('&').enumerate() {
151            if index > 0 {
152                masked.push('&');
153            }
154            match pair.split_once('=') {
155                Some((key, _)) if is_secret_parameter(key) => {
156                    masked.push_str(key);
157                    masked.push_str("=****");
158                }
159                _ => masked.push_str(pair),
160            }
161        }
162    }
163    masked
164}
165
166fn is_secret_parameter(key: &str) -> bool {
167    let key = key.to_ascii_lowercase().replace(['_', '-'], "");
168    matches!(
169        key.as_str(),
170        "authtoken" | "token" | "password" | "pass" | "passwd" | "pwd" | "secret" | "apikey"
171    )
172}
173
174#[cfg(test)]
175mod mask_url_tests {
176    use super::mask_url;
177
178    #[test]
179    fn masks_passwords_and_secret_parameters() {
180        assert_eq!(
181            mask_url("postgres://user:secret@localhost:5432/db"),
182            "postgres://user:****@localhost:5432/db"
183        );
184        assert_eq!(
185            mask_url("postgres://user:pa:ss@localhost/db?sslmode=require"),
186            "postgres://user:****@localhost/db?sslmode=require"
187        );
188        assert_eq!(
189            mask_url("libsql://app.turso.io?authToken=abc.def&tls=true"),
190            "libsql://app.turso.io?authToken=****&tls=true"
191        );
192        assert_eq!(
193            mask_url("mysql://root@localhost/app?password=hunter2"),
194            "mysql://root@localhost/app?password=****"
195        );
196        assert_eq!(mask_url("file:local.db"), "file:local.db");
197    }
198}