diff --git a/apps/framework-cli/src/cli/display/infrastructure.rs b/apps/framework-cli/src/cli/display/infrastructure.rs index 85b4521c2..5a531ec8f 100644 --- a/apps/framework-cli/src/cli/display/infrastructure.rs +++ b/apps/framework-cli/src/cli/display/infrastructure.rs @@ -35,13 +35,15 @@ use crate::framework::core::{ }, plan::InfraPlan, }; +use crate::utilities::constants::NO_ANSI; use crossterm::{execute, style::Print}; +use std::sync::atomic::Ordering; use tracing::info; /// Create the detail indentation string at compile time /// Computed from ACTION_WIDTH (15) + 3 spaces: /// - ACTION_WIDTH spaces for the action column -/// - 1 space after the action symbol (e.g., "+", "-", "~") +/// - 1 space after the action symbol (e.g., "+", "-", "~") /// - 2 spaces for additional indentation of detail lines /// Total: 18 spaces for proper alignment const DETAIL_INDENT: &str = { @@ -267,7 +269,8 @@ fn format_table_display( /// ``` pub fn infra_added(message: &str) { let styled_text = StyledText::from_str("+ ").green(); - write_styled_line(&styled_text, message).expect("failed to write message to terminal"); + let no_ansi = NO_ANSI.load(Ordering::Relaxed); + write_styled_line(&styled_text, message, no_ansi).expect("failed to write message to terminal"); info!("+ {}", message.trim()); } @@ -305,7 +308,8 @@ pub fn infra_added_detailed(title: &str, details: &[String]) { /// ``` pub fn infra_removed(message: &str) { let styled_text = StyledText::from_str("- ").red(); - write_styled_line(&styled_text, message).expect("failed to write message to terminal"); + let no_ansi = NO_ANSI.load(Ordering::Relaxed); + write_styled_line(&styled_text, message, no_ansi).expect("failed to write message to terminal"); info!("- {}", message.trim()); } @@ -343,7 +347,8 @@ pub fn infra_removed_detailed(title: &str, details: &[String]) { /// ``` pub fn infra_updated(message: &str) { let styled_text = StyledText::from_str("~ ").yellow(); - write_styled_line(&styled_text, message).expect("failed to write message to terminal"); + let no_ansi = NO_ANSI.load(Ordering::Relaxed); + write_styled_line(&styled_text, message, no_ansi).expect("failed to write message to terminal"); info!("~ {}", message.trim()); } @@ -388,7 +393,7 @@ pub fn infra_updated_detailed(title: &str, details: &[String]) { /// # Change Types Handled /// /// - **Table Changes**: Added, removed, or updated database tables -/// - **View Changes**: Added, removed, or updated database views +/// - **View Changes**: Added, removed, or updated database views /// - **SQL Resource Changes**: Added, removed, or updated SQL resources /// /// # Examples diff --git a/apps/framework-cli/src/cli/display/message_display.rs b/apps/framework-cli/src/cli/display/message_display.rs index 909c50955..3400c530d 100644 --- a/apps/framework-cli/src/cli/display/message_display.rs +++ b/apps/framework-cli/src/cli/display/message_display.rs @@ -7,6 +7,8 @@ use super::{ message::{Message, MessageType}, terminal::{write_styled_line, StyledText}, }; +use crate::utilities::constants::NO_ANSI; +use std::sync::atomic::Ordering; use tracing::info; /// Displays a message about a batch database insertion. @@ -31,7 +33,8 @@ pub fn batch_inserted(count: usize, table_name: &str) { action: "[DB]".to_string(), details: format!("{count} row(s) successfully written to DB table ({table_name})"), }; - let _ = show_message_impl(MessageType::Info, message, true); + let no_ansi = NO_ANSI.load(Ordering::Relaxed); + let _ = show_message_impl(MessageType::Info, message, true, no_ansi); } /// Wrapper function for the show_message macro. @@ -56,7 +59,8 @@ pub fn batch_inserted(count: usize, table_name: &str) { /// ); /// ``` pub fn show_message_wrapper(message_type: MessageType, message: Message) { - let _ = show_message_impl(message_type, message, false); + let no_ansi = NO_ANSI.load(Ordering::Relaxed); + let _ = show_message_impl(message_type, message, false, no_ansi); } /// Internal implementation for the show_message macro. @@ -69,6 +73,7 @@ pub fn show_message_wrapper(message_type: MessageType, message: Message) { /// * `message_type` - The type of message determining visual style /// * `message` - The message to display /// * `should_log` - Whether to log the message +/// * `no_ansi` - If true, disable ANSI color codes and formatting /// /// # Returns /// @@ -77,6 +82,7 @@ pub fn show_message_impl( message_type: MessageType, message: Message, should_log: bool, + no_ansi: bool, ) -> std::io::Result<()> { let action = message.action.clone(); let details = message.details.clone(); @@ -90,7 +96,7 @@ pub fn show_message_impl( }; // Write styled prefix and details in one line - write_styled_line(&styled_prefix, &details)?; + write_styled_line(&styled_prefix, &details, no_ansi)?; if should_log { let log_action = action.replace('\n', " "); @@ -105,7 +111,8 @@ pub fn show_message_impl( /// /// This macro provides a unified interface for displaying messages with consistent /// formatting and optional logging. It handles the styling based on message type -/// and ensures proper terminal output. +/// and ensures proper terminal output. ANSI color codes are automatically disabled +/// when the `no_ansi` setting is enabled in logger configuration. /// /// # Syntax /// @@ -137,15 +144,31 @@ pub fn show_message_impl( /// ``` #[macro_export] macro_rules! show_message { - ($message_type:expr, $message:expr) => { - $crate::cli::display::message_display::show_message_impl($message_type, $message, true) - .expect("failed to write message to terminal"); - }; + ($message_type:expr, $message:expr) => {{ + use std::sync::atomic::Ordering; + use $crate::utilities::constants::NO_ANSI; + let no_ansi = NO_ANSI.load(Ordering::Relaxed); + $crate::cli::display::message_display::show_message_impl( + $message_type, + $message, + true, + no_ansi, + ) + .expect("failed to write message to terminal"); + }}; - ($message_type:expr, $message:expr, $no_log:expr) => { - $crate::cli::display::message_display::show_message_impl($message_type, $message, false) - .expect("failed to write message to terminal"); - }; + ($message_type:expr, $message:expr, $no_log:expr) => {{ + use std::sync::atomic::Ordering; + use $crate::utilities::constants::NO_ANSI; + let no_ansi = NO_ANSI.load(Ordering::Relaxed); + $crate::cli::display::message_display::show_message_impl( + $message_type, + $message, + false, + no_ansi, + ) + .expect("failed to write message to terminal"); + }}; } #[cfg(test)] @@ -155,35 +178,35 @@ mod tests { #[test] fn test_show_message_impl_info() { let message = Message::new("Test".to_string(), "Test message".to_string()); - let result = show_message_impl(MessageType::Info, message, false); + let result = show_message_impl(MessageType::Info, message, false, false); assert!(result.is_ok()); } #[test] fn test_show_message_impl_success() { let message = Message::new("Success".to_string(), "Operation completed".to_string()); - let result = show_message_impl(MessageType::Success, message, false); + let result = show_message_impl(MessageType::Success, message, false, false); assert!(result.is_ok()); } #[test] fn test_show_message_impl_error() { let message = Message::new("Error".to_string(), "Something went wrong".to_string()); - let result = show_message_impl(MessageType::Error, message, false); + let result = show_message_impl(MessageType::Error, message, false, false); assert!(result.is_ok()); } #[test] fn test_show_message_impl_highlight() { let message = Message::new("Important".to_string(), "Pay attention to this".to_string()); - let result = show_message_impl(MessageType::Highlight, message, false); + let result = show_message_impl(MessageType::Highlight, message, false, false); assert!(result.is_ok()); } #[test] fn test_show_message_impl_with_logging() { let message = Message::new("Log".to_string(), "This should be logged".to_string()); - let result = show_message_impl(MessageType::Info, message, true); + let result = show_message_impl(MessageType::Info, message, true, false); assert!(result.is_ok()); } @@ -193,7 +216,7 @@ mod tests { "Multi\nLine".to_string(), "Details\nwith\nnewlines".to_string(), ); - let result = show_message_impl(MessageType::Info, message, false); + let result = show_message_impl(MessageType::Info, message, false, false); assert!(result.is_ok()); } @@ -203,14 +226,14 @@ mod tests { "🚀 Deploy".to_string(), "Successfully deployed 🎉".to_string(), ); - let result = show_message_impl(MessageType::Success, message, false); + let result = show_message_impl(MessageType::Success, message, false, false); assert!(result.is_ok()); } #[test] fn test_show_message_impl_empty() { let message = Message::new("".to_string(), "".to_string()); - let result = show_message_impl(MessageType::Info, message, false); + let result = show_message_impl(MessageType::Info, message, false, false); assert!(result.is_ok()); } diff --git a/apps/framework-cli/src/cli/display/terminal.rs b/apps/framework-cli/src/cli/display/terminal.rs index 3c5b92793..1886357bd 100644 --- a/apps/framework-cli/src/cli/display/terminal.rs +++ b/apps/framework-cli/src/cli/display/terminal.rs @@ -187,6 +187,7 @@ impl StyledText { /// /// * `styled_text` - The styled text configuration for the action portion /// * `message` - The main message content to display +/// * `no_ansi` - If true, disable ANSI color codes and formatting /// /// # Returns /// @@ -202,12 +203,17 @@ impl StyledText { /// ```rust /// # use crate::cli::display::terminal::{StyledText, write_styled_line}; /// let styled = StyledText::new("Success".to_string()).green().bold(); -/// write_styled_line(&styled, "Operation completed successfully")?; +/// write_styled_line(&styled, "Operation completed successfully", false)?; /// # Ok::<(), std::io::Error>(()) /// ``` -pub fn write_styled_line(styled_text: &StyledText, message: &str) -> IoResult<()> { - let mut stdout = stdout(); - +/// Internal helper that writes a styled action line to any writer. +/// This allows for testing by capturing output to a buffer. +fn write_styled_line_to( + writer: &mut W, + styled_text: &StyledText, + message: &str, + no_ansi: bool, +) -> IoResult<()> { // Ensure action is exactly ACTION_WIDTH characters, right-aligned // Use character-aware truncation to avoid panics on multi-byte UTF-8 characters let truncated_action = if styled_text.text.chars().count() > ACTION_WIDTH { @@ -221,36 +227,46 @@ pub fn write_styled_line(styled_text: &StyledText, message: &str) -> IoResult<() }; let padded_action = format!("{truncated_action:>ACTION_WIDTH$}"); - // Apply foreground color - if let Some(color) = styled_text.foreground { - execute!(stdout, SetForegroundColor(color))?; - } + // Only apply ANSI styling if not disabled + if !no_ansi { + // Apply foreground color + if let Some(color) = styled_text.foreground { + execute!(writer, SetForegroundColor(color))?; + } - // Apply background color - if let Some(color) = styled_text.background { - execute!(stdout, SetBackgroundColor(color))?; - } + // Apply background color + if let Some(color) = styled_text.background { + execute!(writer, SetBackgroundColor(color))?; + } - // Apply bold - if styled_text.bold { - execute!(stdout, SetAttribute(Attribute::Bold))?; + // Apply bold + if styled_text.bold { + execute!(writer, SetAttribute(Attribute::Bold))?; + } } // Write the styled, right-aligned action text - execute!(stdout, Print(&padded_action))?; + execute!(writer, Print(&padded_action))?; - // Reset styling before writing the message - execute!(stdout, ResetColor)?; - if styled_text.bold { - execute!(stdout, SetAttribute(Attribute::Reset))?; + // Reset styling before writing the message (only if ANSI was applied) + if !no_ansi { + execute!(writer, ResetColor)?; + if styled_text.bold { + execute!(writer, SetAttribute(Attribute::Reset))?; + } } // Write separator and message - execute!(stdout, Print(" "), Print(message), Print("\n"))?; + execute!(writer, Print(" "), Print(message), Print("\n"))?; Ok(()) } +pub fn write_styled_line(styled_text: &StyledText, message: &str, no_ansi: bool) -> IoResult<()> { + let mut stdout = stdout(); + write_styled_line_to(&mut stdout, styled_text, message, no_ansi) +} + #[cfg(test)] mod tests { use super::*; @@ -324,13 +340,109 @@ mod tests { let _styled = StyledText::from_str(""); // Just test that empty text doesn't panic } - // Note: write_styled_line is difficult to test without mocking stdout, - // but we can test that it doesn't panic with various inputs + // Tests that actually verify ANSI codes are present/absent in output + // by using write_styled_line_to with a buffer + + #[test] + fn test_write_styled_line_with_ansi_contains_escape_codes() { + let mut buffer = Vec::new(); + let styled = StyledText::from_str("Test").green().bold(); + + // no_ansi = false means ANSI codes SHOULD be present + write_styled_line_to(&mut buffer, &styled, "test message", false).unwrap(); + let output = String::from_utf8(buffer).unwrap(); + + // Check for ANSI escape code prefix (\x1b[ or ESC[) + assert!( + output.contains("\x1b["), + "Output with no_ansi=false should contain ANSI escape codes. Got: {:?}", + output + ); + } + #[test] - fn test_write_styled_line_doesnt_panic() { + fn test_write_styled_line_without_ansi_no_escape_codes() { + let mut buffer = Vec::new(); let styled = StyledText::from_str("Test").green().bold(); - // This test mainly ensures the function signature is correct - // and doesn't panic during compilation - let _ = write_styled_line(&styled, "test message"); + + // no_ansi = true means ANSI codes should NOT be present + write_styled_line_to(&mut buffer, &styled, "test message", true).unwrap(); + let output = String::from_utf8(buffer).unwrap(); + + // Verify no ANSI escape codes + assert!( + !output.contains("\x1b["), + "Output with no_ansi=true should NOT contain ANSI escape codes. Got: {:?}", + output + ); + + // Verify the actual text content is still there + assert!(output.contains("Test"), "Should contain the action text"); + assert!( + output.contains("test message"), + "Should contain the message" + ); + } + + #[test] + fn test_write_styled_line_bold_ansi_code() { + let mut buffer = Vec::new(); + let styled = StyledText::from_str("Bold").bold(); + + write_styled_line_to(&mut buffer, &styled, "message", false).unwrap(); + let output = String::from_utf8(buffer).unwrap(); + + // Bold is attribute 1, should see \x1b[1m + assert!(output.contains("\x1b[1m"), "Should contain bold ANSI code"); + } + + #[test] + fn test_write_styled_line_all_styles_no_ansi_verified() { + // Verify that ALL color/style combinations produce NO ANSI codes with no_ansi=true + let test_cases = vec![ + ("Cyan", StyledText::from_str("Cyan").cyan()), + ("Green", StyledText::from_str("Green").green()), + ("Yellow", StyledText::from_str("Yellow").yellow()), + ("Red", StyledText::from_str("Red").red()), + ("OnGreen", StyledText::from_str("OnGreen").on_green()), + ("Bold", StyledText::from_str("Bold").bold()), + ("Combined", StyledText::from_str("Combined").green().bold()), + ]; + + for (name, styled) in test_cases { + let mut buffer = Vec::new(); + write_styled_line_to(&mut buffer, &styled, "message", true).unwrap(); + let output = String::from_utf8(buffer).unwrap(); + + assert!( + !output.contains("\x1b["), + "Style '{}' should not produce ANSI codes with no_ansi=true. Got: {:?}", + name, + output + ); + } + } + + #[test] + fn test_write_styled_line_all_styles_with_ansi_verified() { + // Verify that color/style combinations DO produce ANSI codes with no_ansi=false + let test_cases = vec![ + ("Cyan", StyledText::from_str("Cyan").cyan()), + ("Green", StyledText::from_str("Green").green()), + ("Bold", StyledText::from_str("Bold").bold()), + ]; + + for (name, styled) in test_cases { + let mut buffer = Vec::new(); + write_styled_line_to(&mut buffer, &styled, "message", false).unwrap(); + let output = String::from_utf8(buffer).unwrap(); + + assert!( + output.contains("\x1b["), + "Style '{}' should produce ANSI codes with no_ansi=false. Got: {:?}", + name, + output + ); + } } } diff --git a/apps/framework-cli/src/cli/logger.rs b/apps/framework-cli/src/cli/logger.rs index da6a939c0..7c6ee89ba 100644 --- a/apps/framework-cli/src/cli/logger.rs +++ b/apps/framework-cli/src/cli/logger.rs @@ -91,7 +91,8 @@ use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{EnvFilter, Layer}; -use crate::utilities::constants::{CONTEXT, CTX_SESSION_ID}; +use crate::utilities::constants::{CONTEXT, CTX_SESSION_ID, NO_ANSI}; +use std::sync::atomic::Ordering; use super::settings::user_directory; @@ -144,6 +145,9 @@ pub struct LoggerSettings { #[serde(default = "default_use_tracing_format")] pub use_tracing_format: bool, + + #[serde(default = "default_no_ansi")] + pub no_ansi: bool, } fn default_log_file() -> String { @@ -173,6 +177,10 @@ fn default_use_tracing_format() -> bool { .unwrap_or(false) } +fn default_no_ansi() -> bool { + false // ANSI colors enabled by default +} + impl Default for LoggerSettings { fn default() -> Self { LoggerSettings { @@ -182,6 +190,7 @@ impl Default for LoggerSettings { format: default_log_format(), include_session_id: default_include_session_id(), use_tracing_format: default_use_tracing_format(), + no_ansi: default_no_ansi(), } } } @@ -453,6 +462,9 @@ fn create_rolling_file_appender(date_format: &str) -> DateBasedWriter { pub fn setup_logging(settings: &LoggerSettings) { clean_old_logs(); + // Set global NO_ANSI flag for terminal display functions + NO_ANSI.store(settings.no_ansi, Ordering::Relaxed); + let session_id = CONTEXT.get(CTX_SESSION_ID).unwrap(); // Setup logging based on format type @@ -469,11 +481,16 @@ fn setup_modern_format(settings: &LoggerSettings) { let env_filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(settings.level.to_tracing_level().to_string())); + // When no_ansi is false, ANSI is enabled (true) + // When no_ansi is true, ANSI is disabled (false) + let ansi_enabled = !settings.no_ansi; + if settings.stdout { let format_layer = tracing_subscriber::fmt::layer() .with_writer(std::io::stdout) .with_target(true) - .with_level(true); + .with_level(true) + .with_ansi(ansi_enabled); if settings.format == LogFormat::Json { tracing_subscriber::registry() @@ -487,11 +504,15 @@ fn setup_modern_format(settings: &LoggerSettings) { .init(); } } else { + // For file output, explicitly disable ANSI codes regardless of no_ansi setting. + // Files are not terminals and don't render colors. tracing-subscriber defaults + // to ANSI=true, so we must explicitly set it to false for file writers. let file_appender = create_rolling_file_appender(&settings.log_file_date_format); let format_layer = tracing_subscriber::fmt::layer() .with_writer(file_appender) .with_target(true) - .with_level(true); + .with_level(true) + .with_ansi(false); if settings.format == LogFormat::Json { tracing_subscriber::registry() diff --git a/apps/framework-cli/src/utilities/constants.rs b/apps/framework-cli/src/utilities/constants.rs index 8dbc237c7..6fe7be15a 100644 --- a/apps/framework-cli/src/utilities/constants.rs +++ b/apps/framework-cli/src/utilities/constants.rs @@ -1,5 +1,6 @@ use lazy_static::lazy_static; use std::collections::HashMap; +use std::sync::atomic::AtomicBool; use uuid::Uuid; pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -82,8 +83,13 @@ lazy_static! { }; } +/// Global flag to disable ANSI colors in terminal output +/// When true, ANSI escape codes are disabled in terminal display functions +/// This is set once at startup based on logger configuration +pub static NO_ANSI: AtomicBool = AtomicBool::new(false); + pub const README_PREFIX: &str = r#" -This is a [MooseJs](https://www.moosejs.com/) project bootstrapped with the +This is a [MooseJs](https://www.moosejs.com/) project bootstrapped with the [`Moose CLI`](https://github.com/514-labs/moose/tree/main/apps/framework-cli). "#;