From e7a31b426349c5ce6e10d92d059e0d6cac651662 Mon Sep 17 00:00:00 2001 From: Droid Agent Date: Tue, 27 Jan 2026 14:36:40 +0000 Subject: [PATCH] feat(cli): add styled output with theme-aware colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new styled_output module that provides human-friendly, colorful CLI messages that automatically adapt to terminal theme (light/dark) and respect the NO_COLOR environment variable. Changes: - Created styled_output.rs with print_success, print_error, print_warning, print_info, and print_dim functions - Messages use themed colors: success (green), error (red), warning (amber), info (blue) - Icons: ✓ for success, ✗ for error, ⚠ for warning, ℹ for info - Automatically detects light/dark theme via COLORFGBG and ITERM_PROFILE - Updated login.rs, main.rs, import_cmd.rs, run_cmd.rs, uninstall_cmd.rs, and github_cmd.rs to use the new styled output The messages are now more human-friendly with clear visual indicators and consistent styling across the CLI. --- cortex-cli/src/github_cmd.rs | 9 +- cortex-cli/src/import_cmd.rs | 23 +- cortex-cli/src/lib.rs | 1 + cortex-cli/src/login.rs | 58 +++-- cortex-cli/src/main.rs | 28 ++- cortex-cli/src/run_cmd.rs | 9 +- cortex-cli/src/styled_output.rs | 421 ++++++++++++++++++++++++++++++++ cortex-cli/src/uninstall_cmd.rs | 19 +- 8 files changed, 502 insertions(+), 66 deletions(-) create mode 100644 cortex-cli/src/styled_output.rs diff --git a/cortex-cli/src/github_cmd.rs b/cortex-cli/src/github_cmd.rs index 1c0a1ef3..48f648ba 100644 --- a/cortex-cli/src/github_cmd.rs +++ b/cortex-cli/src/github_cmd.rs @@ -5,6 +5,7 @@ //! - `cortex github run` - Run GitHub agent in Actions context //! - `cortex github status` - Check installation status +use crate::styled_output::{print_error, print_success, print_warning}; use anyhow::{Context, Result, bail}; use clap::Parser; use std::path::PathBuf; @@ -583,19 +584,19 @@ async fn run_status(args: StatusArgs) -> Result<()> { println!(); if !status.is_git_repo { - eprintln!("\x1b[1;33mWarning:\x1b[0m Not a git repository"); + print_warning("Not a git repository."); println!(" Run this command from a git repository root."); std::process::exit(1); } if !status.github_dir_exists { - eprintln!("\x1b[1;31mError:\x1b[0m .github directory not found"); + print_error(".github directory not found."); println!(" Run `cortex github install` to set up GitHub Actions."); std::process::exit(1); } if status.workflow_installed { - println!("Cortex workflow installed"); + print_success("Cortex workflow is installed."); if let Some(ref path) = status.workflow_path { println!(" Path: {}", path.display()); } @@ -605,7 +606,7 @@ async fn run_status(args: StatusArgs) -> Result<()> { println!(" • {}", feature); } } else { - eprintln!("\x1b[1;31mError:\x1b[0m Cortex workflow not found"); + print_error("Cortex workflow not found."); println!(" Run `cortex github install` to set up GitHub Actions."); std::process::exit(1); } diff --git a/cortex-cli/src/import_cmd.rs b/cortex-cli/src/import_cmd.rs index c6152ea4..8793037c 100644 --- a/cortex-cli/src/import_cmd.rs +++ b/cortex-cli/src/import_cmd.rs @@ -6,6 +6,7 @@ use anyhow::{Context, Result, bail}; use clap::Parser; use std::path::PathBuf; +use crate::styled_output::{print_info, print_success, print_warning}; use cortex_engine::rollout::recorder::{RolloutRecorder, SessionMeta}; use cortex_engine::rollout::{SESSIONS_SUBDIR, get_rollout_path}; use cortex_protocol::{ @@ -109,11 +110,13 @@ impl ImportCommand { if let Ok(orig_id) = original_id { let existing_path = get_rollout_path(&cortex_home, &orig_id); if existing_path.exists() && !self.force { - eprintln!( - "Warning: Original session {} already exists locally.", + print_warning(&format!( + "Original session {} already exists locally.", export.session.id - ); - eprintln!("Creating new session with ID: {new_conversation_id}"); + )); + print_info(&format!( + "Creating new session with ID: {new_conversation_id}" + )); } } @@ -157,7 +160,7 @@ impl ImportCommand { recorder.flush()?; - println!("Imported session as: {new_conversation_id}"); + print_success(&format!("Imported session as: {new_conversation_id}")); println!(" Original ID: {}", export.session.id); if let Some(title) = &export.session.title { println!(" Title: {title}"); @@ -167,7 +170,7 @@ impl ImportCommand { if self.resume { // Launch resume - println!("\nResuming session..."); + print_info("Resuming session..."); let config = cortex_engine::Config::default(); #[cfg(feature = "cortex-tui")] @@ -202,11 +205,11 @@ async fn fetch_url(url: &str) -> Result { && !content_type_str.contains("text/plain") && !content_type_str.is_empty() { - eprintln!( - "Warning: URL returned Content-Type '{}', expected 'application/json'", + print_warning(&format!( + "URL returned Content-Type '{}', expected 'application/json'.", content_type_str - ); - eprintln!("The import may fail if the content is not valid JSON."); + )); + print_info("The import may fail if the content is not valid JSON."); } } diff --git a/cortex-cli/src/lib.rs b/cortex-cli/src/lib.rs index 1d899cfc..faca2ed7 100644 --- a/cortex-cli/src/lib.rs +++ b/cortex-cli/src/lib.rs @@ -69,6 +69,7 @@ pub mod pr_cmd; pub mod run_cmd; pub mod scrape_cmd; pub mod stats_cmd; +pub mod styled_output; pub mod uninstall_cmd; pub mod upgrade_cmd; diff --git a/cortex-cli/src/login.rs b/cortex-cli/src/login.rs index 838a6c2b..d1313629 100644 --- a/cortex-cli/src/login.rs +++ b/cortex-cli/src/login.rs @@ -1,5 +1,6 @@ //! Login command handlers. +use crate::styled_output::{print_dim, print_error, print_info, print_success, print_warning}; use cortex_common::CliConfigOverrides; use cortex_login::{ AuthMode, CredentialsStoreMode, SecureAuthData, load_auth_with_fallback, logout_with_fallback, @@ -25,8 +26,8 @@ fn check_duplicate_config_overrides(config_overrides: &CliConfigOverrides) { } if has_duplicates { - eprintln!( - "Warning: Duplicate config override keys detected. Only the last value for each key will be used." + print_warning( + "Duplicate config override keys detected. Only the last value for each key will be used.", ); } } @@ -53,20 +54,20 @@ pub async fn run_login_with_api_key(config_overrides: CliConfigOverrides, api_ke Ok(mode) => { match mode { CredentialsStoreMode::Keyring => { - eprintln!("Successfully logged in (credentials stored in system keyring)"); + print_success("Logged in successfully. Credentials stored in system keyring."); } CredentialsStoreMode::EncryptedFile => { - eprintln!("Successfully logged in (credentials stored in encrypted file)"); - eprintln!("Note: System keyring unavailable, using encrypted file storage"); + print_success("Logged in successfully. Credentials stored in encrypted file."); + print_dim("System keyring unavailable, using encrypted file storage."); } CredentialsStoreMode::File => { - eprintln!("Successfully logged in (legacy storage)"); + print_success("Logged in successfully (legacy storage)."); } } std::process::exit(0); } Err(e) => { - eprintln!("Error logging in: {e}"); + print_error(&format!("Login failed: {e}")); std::process::exit(1); } } @@ -92,11 +93,11 @@ pub async fn run_login_with_device_code( match cortex_login::run_device_code_login(opts).await { Ok(()) => { - eprintln!("Successfully logged in"); + print_success("Logged in successfully."); std::process::exit(0); } Err(e) => { - eprintln!("Error logging in with device code: {e}"); + print_error(&format!("Login failed: {e}")); std::process::exit(1); } } @@ -112,27 +113,30 @@ pub async fn run_login_status(config_overrides: CliConfigOverrides) -> ! { Ok(Some(auth)) => match auth.mode { AuthMode::ApiKey => { if let Some(key) = auth.get_token() { - eprintln!("Logged in using an API key - {}", safe_format_key(key)); + print_success(&format!( + "Logged in using an API key: {}", + safe_format_key(key) + )); std::process::exit(0); } else { - eprintln!("Logged in but no token available"); + print_warning("Logged in but no token available."); std::process::exit(1); } } AuthMode::OAuth => { - eprintln!("Logged in using OAuth"); + print_success("Logged in using OAuth."); if auth.is_expired() { - eprintln!("(token may be expired)"); + print_warning("Token may be expired."); } std::process::exit(0); } }, Ok(None) => { - eprintln!("Not logged in"); + print_info("Not logged in."); std::process::exit(1); } Err(e) => { - eprintln!("Error checking login status: {e}"); + print_error(&format!("Failed to check login status: {e}")); std::process::exit(1); } } @@ -162,18 +166,18 @@ pub async fn run_logout(config_overrides: CliConfigOverrides, skip_confirmation: if std::io::stdin().read_line(&mut input).is_ok() { let input = input.trim().to_lowercase(); if input != "y" && input != "yes" { - eprintln!("Logout cancelled."); + print_info("Logout cancelled."); std::process::exit(0); } } } } Ok(None) => { - eprintln!("Not logged in"); + print_info("Not logged in."); std::process::exit(0); } Err(e) => { - eprintln!("Error checking login status: {e}"); + print_error(&format!("Failed to check login status: {e}")); std::process::exit(1); } } @@ -183,15 +187,15 @@ pub async fn run_logout(config_overrides: CliConfigOverrides, skip_confirmation: // may have stored them in encrypted file when keyring was unavailable match logout_with_fallback(&cortex_home) { Ok(true) => { - eprintln!("Successfully logged out"); + print_success("Logged out successfully. Credentials have been removed."); std::process::exit(0); } Ok(false) => { - eprintln!("Not logged in"); + print_info("Not logged in."); std::process::exit(0); } Err(e) => { - eprintln!("Error logging out: {e}"); + print_error(&format!("Failed to log out: {e}")); std::process::exit(1); } } @@ -202,24 +206,24 @@ pub fn read_api_key_from_stdin() -> String { let mut stdin = std::io::stdin(); if stdin.is_terminal() { - eprintln!( - "--with-api-key expects the API key on stdin. Try piping it, e.g. \ - `printenv OPENAI_API_KEY | cortex login --with-api-key`." + print_error( + "The --with-api-key flag expects input from stdin. Try piping it: \ + `printenv OPENAI_API_KEY | cortex login --with-api-key`", ); std::process::exit(1); } - eprintln!("Reading API key from stdin..."); + print_info("Reading API key from stdin..."); let mut buffer = String::new(); if let Err(err) = stdin.read_to_string(&mut buffer) { - eprintln!("Failed to read API key from stdin: {err}"); + print_error(&format!("Failed to read API key from stdin: {err}")); std::process::exit(1); } let api_key = buffer.trim().to_string(); if api_key.is_empty() { - eprintln!("No API key provided via stdin."); + print_error("No API key provided via stdin."); std::process::exit(1); } diff --git a/cortex-cli/src/main.rs b/cortex-cli/src/main.rs index 9fd9098b..ca058eee 100644 --- a/cortex-cli/src/main.rs +++ b/cortex-cli/src/main.rs @@ -32,6 +32,7 @@ use cortex_cli::pr_cmd::PrCli; use cortex_cli::run_cmd::RunCli; use cortex_cli::scrape_cmd::ScrapeCommand; use cortex_cli::stats_cmd::StatsCli; +use cortex_cli::styled_output::{print_error, print_info, print_success, print_warning}; use cortex_cli::uninstall_cmd::UninstallCli; use cortex_cli::upgrade_cmd::UpgradeCli; use cortex_cli::{LandlockCommand, SeatbeltCommand, WindowsCommand}; @@ -752,10 +753,10 @@ async fn run_resume(resume_cli: ResumeCommand) -> Result<()> { (None, true) | (None, false) => { let sessions = cortex_engine::list_sessions(&config.cortex_home)?; if sessions.is_empty() { - println!("No sessions found to resume."); + print_info("No sessions found to resume."); return Ok(()); } - println!("Resuming most recent session..."); + print_info("Resuming most recent session..."); sessions[0].id.clone() } }; @@ -792,7 +793,7 @@ async fn run_resume(resume_cli: ResumeCommand) -> Result<()> { } }; - println!("Resuming session: {conversation_id}"); + print_success(&format!("Resuming session: {conversation_id}")); #[cfg(feature = "cortex-tui")] { @@ -917,7 +918,7 @@ async fn list_sessions( // TODO: Integrate with cortex-storage SessionStorage to filter favorites // For now, this is a placeholder - favorites data is stored in cortex-storage // and would need to be cross-referenced with the rollout-based sessions. - eprintln!("Note: --favorites filter requires session metadata from cortex-storage"); + print_info("The --favorites filter requires session metadata from cortex-storage."); } if sessions.is_empty() { @@ -1109,7 +1110,7 @@ async fn show_config(config_cli: ConfigCommand) -> Result<()> { .status()?; if !status.success() { - eprintln!("Editor exited with error"); + print_error("Editor exited with an error."); } } else { println!("Cortex Configuration:"); @@ -1202,7 +1203,7 @@ fn check_special_ip_address(host: &str) { for (pattern, warning) in special_addresses { if host == pattern || host.starts_with(pattern) { - eprintln!("Warning: {} - {}", host, warning); + print_warning(&format!("{} - {}", host, warning)); break; } } @@ -1250,10 +1251,10 @@ async fn run_serve(serve_cli: ServeCommand) -> Result<()> { ..Default::default() }; - println!( - "Starting Cortex server on {}:{}", + print_info(&format!( + "Starting Cortex server on {}:{}...", serve_cli.host, serve_cli.port - ); + )); // Setup mDNS advertising if enabled let mut mdns_service = if serve_cli.mdns && !serve_cli.no_mdns { @@ -1266,11 +1267,14 @@ async fn run_serve(serve_cli: ServeCommand) -> Result<()> { match mdns.advertise(serve_cli.port, &service_name).await { Ok(info) => { - println!("mDNS: Advertising as '{}' on port {}", info.name, info.port); + print_success(&format!( + "mDNS: Advertising as '{}' on port {}", + info.name, info.port + )); Some(mdns) } Err(e) => { - eprintln!("Warning: Failed to start mDNS advertising: {e}"); + print_warning(&format!("Failed to start mDNS advertising: {e}")); None } } @@ -1284,7 +1288,7 @@ async fn run_serve(serve_cli: ServeCommand) -> Result<()> { // Stop mDNS advertising on shutdown if let Some(ref mut mdns) = mdns_service { if let Err(e) = mdns.stop_advertising().await { - eprintln!("Warning: Failed to stop mDNS advertising: {e}"); + print_warning(&format!("Failed to stop mDNS advertising: {e}")); } } diff --git a/cortex-cli/src/run_cmd.rs b/cortex-cli/src/run_cmd.rs index 5881d082..ddd30a97 100644 --- a/cortex-cli/src/run_cmd.rs +++ b/cortex-cli/src/run_cmd.rs @@ -30,6 +30,7 @@ use std::time::Duration; /// Maximum file size for attachments (10MB) const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; +use crate::styled_output::{print_success, print_warning}; use cortex_common::resolve_model_alias; use cortex_engine::rollout::get_rollout_path; use cortex_engine::{Session, list_sessions}; @@ -455,7 +456,7 @@ impl RunCli { // TODO: Implement full server attachment when cortex SDK client is available // For now, fall back to local execution with a warning - eprintln!("Warning: Server attachment not yet fully implemented, running locally"); + print_warning("Server attachment not yet fully implemented. Running locally instead."); self.run_local(message, attachments, session_mode).await } @@ -785,7 +786,7 @@ impl RunCli { ); } } else { - eprintln!("Warning: Failed to copy to clipboard"); + print_warning("Failed to copy to clipboard."); } } @@ -800,10 +801,10 @@ impl RunCli { let share_manager = ShareManager::new(); match share_manager.share(&session_id).await { Ok(shared) => { - eprintln!("✓ Session shared: {}", shared.url); + print_success(&format!("Session shared: {}", shared.url)); } Err(e) => { - eprintln!("Warning: Failed to share session: {}", e); + print_warning(&format!("Failed to share session: {}", e)); } } } diff --git a/cortex-cli/src/styled_output.rs b/cortex-cli/src/styled_output.rs new file mode 100644 index 00000000..19aedb9c --- /dev/null +++ b/cortex-cli/src/styled_output.rs @@ -0,0 +1,421 @@ +//! Styled CLI output with theme-aware colors. +//! +//! Provides human-friendly, colorful messages for CLI operations that +//! automatically adapt to the terminal's color capabilities and respect +//! the NO_COLOR environment variable. +//! +//! # Examples +//! +//! ``` +//! use cortex_cli::styled_output::{print_success, print_error, print_warning, print_info}; +//! +//! print_success("Operation completed successfully"); +//! print_error("Failed to connect to server"); +//! print_warning("Configuration file not found, using defaults"); +//! print_info("Processing 42 files..."); +//! ``` + +use std::io::{IsTerminal, Write}; + +/// Check if colors should be disabled based on NO_COLOR env var. +fn colors_disabled() -> bool { + std::env::var("NO_COLOR") + .map(|v| !v.is_empty() && v != "0" && v.to_lowercase() != "false") + .unwrap_or(false) +} + +/// Check if the output is a terminal (TTY). +fn is_terminal_output(stderr: bool) -> bool { + if stderr { + std::io::stderr().is_terminal() + } else { + std::io::stdout().is_terminal() + } +} + +/// ANSI color codes for light theme (bright/light terminal backgrounds). +mod light_theme { + pub const SUCCESS: &str = "\x1b[38;2;0;150;100m"; // Dark green for contrast + pub const ERROR: &str = "\x1b[38;2;180;40;40m"; // Dark red for contrast + pub const WARNING: &str = "\x1b[38;2;180;120;0m"; // Dark amber for contrast + pub const INFO: &str = "\x1b[38;2;0;100;160m"; // Dark blue for contrast + pub const DIM: &str = "\x1b[38;2;100;100;100m"; // Gray for muted text + pub const BOLD: &str = "\x1b[1m"; + pub const RESET: &str = "\x1b[0m"; +} + +/// ANSI color codes for dark theme (dark terminal backgrounds). +mod dark_theme { + pub const SUCCESS: &str = "\x1b[38;2;0;245;212m"; // Bright cyan-green (#00F5D4) + pub const ERROR: &str = "\x1b[38;2;255;107;107m"; // Coral red (#FF6B6B) + pub const WARNING: &str = "\x1b[38;2;255;200;87m"; // Golden amber (#FFC857) + pub const INFO: &str = "\x1b[38;2;72;202;228m"; // Light blue (#48CAE4) + pub const DIM: &str = "\x1b[38;2;130;154;177m"; // Dim text (#829AB1) + pub const BOLD: &str = "\x1b[1m"; + pub const RESET: &str = "\x1b[0m"; +} + +/// Detect if the terminal has a light background. +/// +/// Checks environment variables like COLORFGBG to determine theme. +/// Returns false (dark theme) by default if detection fails. +fn is_light_theme() -> bool { + // Check COLORFGBG first (format: "fg;bg" where bg is 0 for black, 15 for white) + if let Ok(colorfgbg) = std::env::var("COLORFGBG") { + if let Some(bg_str) = colorfgbg.split(';').last() { + if let Ok(bg_num) = bg_str.parse::() { + return bg_num >= 7; // 7+ typically indicates light background + } + } + } + + // Check for common light theme indicators in ITERM_PROFILE, TERM_PROFILE, etc. + if let Ok(profile) = std::env::var("ITERM_PROFILE") { + let profile_lower = profile.to_lowercase(); + if profile_lower.contains("light") || profile_lower.contains("solarized light") { + return true; + } + } + + // Default to dark theme (most common) + false +} + +/// Get the appropriate color codes based on terminal theme. +fn get_theme_colors() -> ( + &'static str, + &'static str, + &'static str, + &'static str, + &'static str, + &'static str, + &'static str, +) { + if is_light_theme() { + ( + light_theme::SUCCESS, + light_theme::ERROR, + light_theme::WARNING, + light_theme::INFO, + light_theme::DIM, + light_theme::BOLD, + light_theme::RESET, + ) + } else { + ( + dark_theme::SUCCESS, + dark_theme::ERROR, + dark_theme::WARNING, + dark_theme::INFO, + dark_theme::DIM, + dark_theme::BOLD, + dark_theme::RESET, + ) + } +} + +/// Message type for styled output. +#[derive(Debug, Clone, Copy)] +pub enum MessageType { + /// Success message (green checkmark) + Success, + /// Error message (red X) + Error, + /// Warning message (yellow/amber warning sign) + Warning, + /// Info message (blue info icon) + Info, + /// Neutral/dimmed message + Dim, +} + +impl MessageType { + /// Get the icon for this message type. + fn icon(&self) -> &'static str { + match self { + MessageType::Success => "✓", + MessageType::Error => "✗", + MessageType::Warning => "⚠", + MessageType::Info => "ℹ", + MessageType::Dim => "·", + } + } + + /// Get the color code for this message type. + fn color(&self) -> &'static str { + let (success, error, warning, info, dim, _, _) = get_theme_colors(); + match self { + MessageType::Success => success, + MessageType::Error => error, + MessageType::Warning => warning, + MessageType::Info => info, + MessageType::Dim => dim, + } + } +} + +/// Internal function to print a styled message. +fn print_styled_internal(msg_type: MessageType, message: &str, to_stderr: bool, bold: bool) { + let use_colors = !colors_disabled() && is_terminal_output(to_stderr); + let (_, _, _, _, _, bold_code, reset) = get_theme_colors(); + let color = msg_type.color(); + let icon = msg_type.icon(); + + let formatted = if use_colors { + if bold { + format!("{}{} {}{}", color, icon, message, reset) + } else { + format!("{}{}{} {}", color, bold_code, icon, message) + .replace(bold_code, "") // Remove bold for non-bold messages + + reset + } + } else { + format!("{} {}", icon, message) + }; + + if use_colors { + if to_stderr { + let _ = write!(std::io::stderr(), "{}{} {}{}", color, icon, message, reset); + let _ = writeln!(std::io::stderr()); + } else { + let _ = write!(std::io::stdout(), "{}{} {}{}", color, icon, message, reset); + let _ = writeln!(std::io::stdout()); + } + } else { + if to_stderr { + eprintln!("{}", formatted); + } else { + println!("{}", formatted); + } + } +} + +// ============================================================ +// PUBLIC API - Simple functions for common use cases +// ============================================================ + +/// Print a success message to stderr. +/// +/// Displays a green checkmark followed by the message. +/// Respects NO_COLOR and terminal theme. +/// +/// # Example +/// ``` +/// print_success("Operation completed successfully"); +/// // Output: ✓ Operation completed successfully (in green) +/// ``` +pub fn print_success(message: &str) { + print_styled_internal(MessageType::Success, message, true, false); +} + +/// Print an error message to stderr. +/// +/// Displays a red X followed by the message. +/// Respects NO_COLOR and terminal theme. +/// +/// # Example +/// ``` +/// print_error("Failed to connect to server"); +/// // Output: ✗ Failed to connect to server (in red) +/// ``` +pub fn print_error(message: &str) { + print_styled_internal(MessageType::Error, message, true, false); +} + +/// Print a warning message to stderr. +/// +/// Displays an amber/yellow warning sign followed by the message. +/// Respects NO_COLOR and terminal theme. +/// +/// # Example +/// ``` +/// print_warning("Configuration file not found, using defaults"); +/// // Output: ⚠ Configuration file not found, using defaults (in amber) +/// ``` +pub fn print_warning(message: &str) { + print_styled_internal(MessageType::Warning, message, true, false); +} + +/// Print an info message to stderr. +/// +/// Displays a blue info icon followed by the message. +/// Respects NO_COLOR and terminal theme. +/// +/// # Example +/// ``` +/// print_info("Processing 42 files..."); +/// // Output: ℹ Processing 42 files... (in blue) +/// ``` +pub fn print_info(message: &str) { + print_styled_internal(MessageType::Info, message, true, false); +} + +/// Print a dimmed/muted message to stderr. +/// +/// Displays a gray bullet followed by the message. +/// Useful for secondary information or notes. +/// +/// # Example +/// ``` +/// print_dim("Note: Using default configuration"); +/// // Output: · Note: Using default configuration (in gray) +/// ``` +pub fn print_dim(message: &str) { + print_styled_internal(MessageType::Dim, message, true, false); +} + +// ============================================================ +// STDOUT VARIANTS - For output that should go to stdout +// ============================================================ + +/// Print a success message to stdout. +pub fn println_success(message: &str) { + print_styled_internal(MessageType::Success, message, false, false); +} + +/// Print an error message to stdout. +pub fn println_error(message: &str) { + print_styled_internal(MessageType::Error, message, false, false); +} + +/// Print a warning message to stdout. +pub fn println_warning(message: &str) { + print_styled_internal(MessageType::Warning, message, false, false); +} + +/// Print an info message to stdout. +pub fn println_info(message: &str) { + print_styled_internal(MessageType::Info, message, false, false); +} + +/// Print a dimmed message to stdout. +pub fn println_dim(message: &str) { + print_styled_internal(MessageType::Dim, message, false, false); +} + +// ============================================================ +// FORMAT FUNCTIONS - Return formatted strings without printing +// ============================================================ + +/// Format a success message (returns the formatted string). +pub fn format_success(message: &str) -> String { + format_styled(MessageType::Success, message) +} + +/// Format an error message (returns the formatted string). +pub fn format_error(message: &str) -> String { + format_styled(MessageType::Error, message) +} + +/// Format a warning message (returns the formatted string). +pub fn format_warning(message: &str) -> String { + format_styled(MessageType::Warning, message) +} + +/// Format an info message (returns the formatted string). +pub fn format_info(message: &str) -> String { + format_styled(MessageType::Info, message) +} + +/// Format a dimmed message (returns the formatted string). +pub fn format_dim(message: &str) -> String { + format_styled(MessageType::Dim, message) +} + +/// Format a styled message and return the string. +fn format_styled(msg_type: MessageType, message: &str) -> String { + let use_colors = !colors_disabled() && is_terminal_output(true); + let (_, _, _, _, _, _, reset) = get_theme_colors(); + let color = msg_type.color(); + let icon = msg_type.icon(); + + if use_colors { + format!("{}{} {}{}", color, icon, message, reset) + } else { + format!("{} {}", icon, message) + } +} + +// ============================================================ +// STYLED LABEL - For inline colored labels +// ============================================================ + +/// Return a styled label string for inline use. +/// +/// # Example +/// ``` +/// println!("Status: {}", styled_label(MessageType::Success, "PASSED")); +/// // Output: Status: ✓ PASSED (with PASSED in green) +/// ``` +pub fn styled_label(msg_type: MessageType, label: &str) -> String { + let use_colors = !colors_disabled() && is_terminal_output(true); + let (_, _, _, _, _, _, reset) = get_theme_colors(); + let color = msg_type.color(); + + if use_colors { + format!("{}{}{}", color, label, reset) + } else { + label.to_string() + } +} + +/// Return a styled icon string without the message. +pub fn styled_icon(msg_type: MessageType) -> String { + let use_colors = !colors_disabled() && is_terminal_output(true); + let (_, _, _, _, _, _, reset) = get_theme_colors(); + let color = msg_type.color(); + let icon = msg_type.icon(); + + if use_colors { + format!("{}{}{}", color, icon, reset) + } else { + icon.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_message_type_icons() { + assert_eq!(MessageType::Success.icon(), "✓"); + assert_eq!(MessageType::Error.icon(), "✗"); + assert_eq!(MessageType::Warning.icon(), "⚠"); + assert_eq!(MessageType::Info.icon(), "ℹ"); + assert_eq!(MessageType::Dim.icon(), "·"); + } + + #[test] + fn test_format_styled_no_color() { + // When colors are disabled, should just return icon + message + std::env::set_var("NO_COLOR", "1"); + let result = format_styled(MessageType::Success, "test message"); + assert!(result.contains("✓")); + assert!(result.contains("test message")); + std::env::remove_var("NO_COLOR"); + } + + #[test] + fn test_colors_disabled() { + std::env::set_var("NO_COLOR", "1"); + assert!(colors_disabled()); + std::env::remove_var("NO_COLOR"); + + std::env::set_var("NO_COLOR", "true"); + assert!(colors_disabled()); + std::env::remove_var("NO_COLOR"); + + std::env::set_var("NO_COLOR", "0"); + assert!(!colors_disabled()); + std::env::remove_var("NO_COLOR"); + + std::env::set_var("NO_COLOR", "false"); + assert!(!colors_disabled()); + std::env::remove_var("NO_COLOR"); + + std::env::set_var("NO_COLOR", ""); + assert!(!colors_disabled()); + std::env::remove_var("NO_COLOR"); + } +} diff --git a/cortex-cli/src/uninstall_cmd.rs b/cortex-cli/src/uninstall_cmd.rs index cfe27a80..d27fcc2c 100644 --- a/cortex-cli/src/uninstall_cmd.rs +++ b/cortex-cli/src/uninstall_cmd.rs @@ -9,6 +9,7 @@ //! - Show what would be deleted (dry-run mode) //! - Create backup before removal +use crate::styled_output::{print_info, print_warning}; use anyhow::{Context, Result, bail}; use clap::Parser; use std::collections::HashMap; @@ -163,9 +164,8 @@ impl UninstallCli { println!("Total space to free: {}", format_size(total_size)); if has_sudo_items { - eprintln!( - "\n\x1b[1;33mWarning:\x1b[0m Some items require elevated privileges to remove." - ); + println!(); + print_warning("Some items require elevated privileges to remove."); println!(" You may be prompted for your password."); } @@ -177,13 +177,13 @@ impl UninstallCli { // Backup if requested if self.backup { - println!("\nCreating backup..."); + print_info("Creating backup..."); if let Err(e) = create_backup(&items_to_remove) { - eprintln!("Warning: Failed to create backup: {e}"); + print_warning(&format!("Failed to create backup: {e}")); if !self.force && !self.yes { println!("Continue without backup? [y/N]"); if !prompt_yes_no()? { - println!("Uninstall cancelled."); + print_info("Uninstall cancelled."); return Ok(()); } } @@ -224,9 +224,9 @@ impl UninstallCli { .iter() .any(|i| i.category == RemovalCategory::Completions) { - println!("\nCleaning shell configuration..."); + print_info("Cleaning shell configuration..."); if let Err(e) = clean_shell_completions() { - eprintln!(" Warning: Failed to clean shell config: {e}"); + print_warning(&format!("Failed to clean shell config: {e}")); } } @@ -237,7 +237,8 @@ impl UninstallCli { println!(" Space freed: {}", format_size(total_size)); if !errors.is_empty() { - eprintln!("\n\x1b[1;33mWarning:\x1b[0m Some items could not be removed:"); + println!(); + print_warning("Some items could not be removed:"); for (path, error) in &errors { println!(" {} - {error}", path.display()); }