drizzle-cli 0.3.1

Command-line interface for drizzle-rs migrations
Documentation
//! Terminal styling helpers, so every command formats output the same way
//! (in the style of drizzle-kit).
//!
//! Each helper returns the text wrapped in ANSI color codes (via the
//! `colored` crate, which honors `NO_COLOR`).

use colored::Colorize;

/// Bright cyan, for section headings.
#[must_use]
pub fn heading(text: &str) -> String {
    format!("{}", text.bright_cyan())
}

/// Bright blue, for field labels.
#[must_use]
pub fn label(text: &str) -> String {
    format!("{}", text.bright_blue())
}

/// Dim (bright black), for secondary text.
#[must_use]
pub fn muted(text: &str) -> String {
    format!("{}", text.bright_black())
}

/// Bright green, for success messages.
#[must_use]
pub fn success(text: &str) -> String {
    format!("{}", text.bright_green())
}

/// Yellow, for warnings.
#[must_use]
pub fn warning(text: &str) -> String {
    format!("{}", text.yellow())
}

/// Red, for errors.
#[must_use]
pub fn error(text: &str) -> String {
    format!("{}", text.red())
}

/// Prefixes `text` with a bold blue `Info:`.
#[must_use]
pub fn info(text: &str) -> String {
    format!("{} {}", "Info:".bright_blue().bold(), text)
}

/// Formats `[Warning] text` with a yellow tag.
#[must_use]
pub fn warn_line(text: &str) -> String {
    format!("[{}] {}", "Warning".yellow(), text)
}

/// Prefixes `text` with a bold red `Error`; used for the final error the CLI prints.
#[must_use]
pub fn err_line(text: &str) -> String {
    format!("{} {}", "Error".red().bold(), text)
}

/// A red ` Invalid input ` badge followed by `text` in red.
#[must_use]
pub fn banner_invalid_input(text: &str) -> String {
    format!("{} {}", " Invalid input ".white().on_red(), text.red())
}

/// A grey ` Warning ` badge followed by `text`.
#[must_use]
pub fn banner_warning(text: &str) -> String {
    format!("{} {}", " Warning ".white().on_bright_black(), text)
}

/// A red ` Error ` badge followed by `text`.
#[must_use]
pub fn banner_error(text: &str) -> String {
    format!("{} {}", " Error ".white().on_red().bold(), text)
}

/// A grey ` Suggestion ` badge followed by `text`.
#[must_use]
pub fn banner_suggestion(text: &str) -> String {
    format!("{} {}", " Suggestion ".white().on_bright_black(), text)
}

/// Green `OK`, for check results.
#[must_use]
pub fn status_ok() -> String {
    format!("{}", "OK".green())
}

/// Red `ERROR`, for check results.
#[must_use]
pub fn status_error() -> String {
    format!("{}", "ERROR".red())
}

/// `text` in yellow, for check results.
#[must_use]
pub fn status_warning(text: &str) -> String {
    format!("{}", text.yellow())
}

/// Hides the secrets in a connection URL: the password of `user:password@`
/// and the value of `authToken`, `password`, `token` and similar query
/// parameters.
///
/// # Examples
///
/// ```rust
/// use drizzle_cli::output::mask_url;
///
/// assert_eq!(
///     mask_url("postgres://user:secret@localhost:5432/db?sslmode=require"),
///     "postgres://user:****@localhost:5432/db?sslmode=require"
/// );
/// assert_eq!(
///     mask_url("libsql://app.turso.io?authToken=abc"),
///     "libsql://app.turso.io?authToken=****"
/// );
/// ```
#[must_use]
pub fn mask_url(url: &str) -> String {
    let (base, query) = match url.split_once('?') {
        Some((base, query)) => (base, Some(query)),
        None => (url, None),
    };

    let mut masked = String::with_capacity(url.len());
    let scheme_end = base.find("://").map_or(0, |position| position + 3);
    match base
        .rfind('@')
        .filter(|&at| at > scheme_end)
        .and_then(|at| {
            base[scheme_end..at]
                .find(':')
                .map(|colon| (scheme_end + colon, at))
        }) {
        Some((colon, at)) => {
            masked.push_str(&base[..=colon]);
            masked.push_str("****");
            masked.push_str(&base[at..]);
        }
        None => masked.push_str(base),
    }

    if let Some(query) = query {
        masked.push('?');
        for (index, pair) in query.split('&').enumerate() {
            if index > 0 {
                masked.push('&');
            }
            match pair.split_once('=') {
                Some((key, _)) if is_secret_parameter(key) => {
                    masked.push_str(key);
                    masked.push_str("=****");
                }
                _ => masked.push_str(pair),
            }
        }
    }
    masked
}

fn is_secret_parameter(key: &str) -> bool {
    let key = key.to_ascii_lowercase().replace(['_', '-'], "");
    matches!(
        key.as_str(),
        "authtoken" | "token" | "password" | "pass" | "passwd" | "pwd" | "secret" | "apikey"
    )
}

#[cfg(test)]
mod mask_url_tests {
    use super::mask_url;

    #[test]
    fn masks_passwords_and_secret_parameters() {
        assert_eq!(
            mask_url("postgres://user:secret@localhost:5432/db"),
            "postgres://user:****@localhost:5432/db"
        );
        assert_eq!(
            mask_url("postgres://user:pa:ss@localhost/db?sslmode=require"),
            "postgres://user:****@localhost/db?sslmode=require"
        );
        assert_eq!(
            mask_url("libsql://app.turso.io?authToken=abc.def&tls=true"),
            "libsql://app.turso.io?authToken=****&tls=true"
        );
        assert_eq!(
            mask_url("mysql://root@localhost/app?password=hunter2"),
            "mysql://root@localhost/app?password=****"
        );
        assert_eq!(mask_url("file:local.db"), "file:local.db");
    }
}