//! Console - the central entry point for styled terminal output.
//!
//! The [`Console`] handles rendering styled content to the terminal,
//! including color detection, width calculation, and ANSI code generation.
//!
//! # Examples
//!
//! ## Basic Printing with Markup
//!
//! ```rust,ignore
//! use rich_rust::Console;
//!
//! let console = Console::new();
//!
//! // Print with markup syntax
//! console.print("[bold red]Error:[/] Something went wrong");
//! console.print("[green]Success![/] Operation completed");
//!
//! // Markup supports colors, attributes, and combinations
//! console.print("[bold italic #ff8800 on blue]Custom styling[/]");
//! ```
//!
//! ## Console Builder
//!
//! ```rust,ignore
//! use rich_rust::console::{Console, ConsoleBuilder};
//! use rich_rust::color::ColorSystem;
//!
//! let console = Console::builder()
//! .color_system(ColorSystem::EightBit) // Force 256 colors
//! .width(80) // Fixed width
//! .markup(true) // Enable markup parsing
//! .build();
//! ```
//!
//! ## Print Options
//!
//! ```rust,ignore
//! use rich_rust::console::{Console, PrintOptions};
//! use rich_rust::style::Style;
//! use rich_rust::text::JustifyMethod;
//!
//! let console = Console::new();
//!
//! let options = PrintOptions::new()
//! .with_style(Style::new().bold())
//! .with_justify(JustifyMethod::Center)
//! .with_markup(true);
//!
//! console.print_with_options("Centered bold text", &options);
//! ```
//!
//! ## Capturing Output
//!
//! ```rust,ignore
//! use rich_rust::Console;
//!
//! let mut console = Console::new();
//!
//! // Start capturing
//! console.begin_capture();
//! console.print("[bold]Hello[/]");
//!
//! // Get captured segments
//! let segments = console.end_capture();
//! for seg in &segments {
//! println!("Text: {:?}, Style: {:?}", seg.text, seg.style);
//! }
//! ```
//!
//! # Terminal Detection
//!
//! The Console automatically detects terminal capabilities:
//!
//! - **Color system**: `TrueColor` (24-bit), 256 colors, or 16 colors
//! - **Terminal dimensions**: Width and height in character cells
//! - **TTY status**: Whether output is to an interactive terminal
//!
//! You can override these with the builder pattern or by setting explicit values.
use std::collections::HashMap;
use std::fmt::Write as FmtWrite;
use std::io::{self, Write};
use std::sync::{
Arc, Mutex, Weak,
atomic::{AtomicBool, Ordering},
};
use time::OffsetDateTime;
use crate::color::{ColorSystem, DEFAULT_TERMINAL_THEME, SVG_EXPORT_THEME, TerminalTheme};
use crate::emoji;
use crate::highlighter::{Highlighter, ReprHighlighter};
use crate::live::LiveInner;
use crate::markup;
use crate::measure::{Measurement, RichMeasure};
use crate::protocol::{RichCast, RichCastOutput};
use crate::renderables::Renderable;
use crate::segment::{ControlCode, ControlType, Segment};
use crate::style::{Attributes, Style, StyleParseError};
use crate::sync::lock_recover;
use crate::terminal;
use crate::text::{JustifyMethod, OverflowMethod, Text};
use crate::theme::{Theme, ThemeStack, ThemeStackError};
/// Console dimensions in cells.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ConsoleDimensions {
/// Width in cells.
pub width: usize,
/// Height in rows.
pub height: usize,
}
impl Default for ConsoleDimensions {
fn default() -> Self {
Self {
width: 80,
height: 24,
}
}
}
/// Options for rendering.
#[derive(Debug, Clone)]
pub struct ConsoleOptions {
/// Terminal dimensions.
pub size: ConsoleDimensions,
/// Using legacy Windows console.
pub legacy_windows: bool,
/// Minimum width constraint.
pub min_width: usize,
/// Maximum width constraint.
pub max_width: usize,
/// Output is a terminal (vs file/pipe).
pub is_terminal: bool,
/// Output encoding.
pub encoding: String,
/// Maximum height for rendering.
pub max_height: usize,
/// Default justification.
pub justify: Option<JustifyMethod>,
/// Default overflow handling.
pub overflow: Option<OverflowMethod>,
/// Default `no_wrap` setting.
pub no_wrap: Option<bool>,
/// Enable highlighting.
pub highlight: Option<bool>,
/// Parse markup in strings.
pub markup: Option<bool>,
/// Explicit height override.
pub height: Option<usize>,
}
impl Default for ConsoleOptions {
fn default() -> Self {
Self {
size: ConsoleDimensions::default(),
legacy_windows: false,
min_width: 1,
max_width: 80,
is_terminal: true,
encoding: String::from("utf-8"),
max_height: usize::MAX,
justify: None,
overflow: None,
no_wrap: None,
highlight: None,
markup: None,
height: None,
}
}
}
impl ConsoleOptions {
/// Create options with a different `max_width`.
#[must_use]
pub fn update_width(&self, width: usize) -> Self {
Self {
max_width: width.min(self.max_width),
..self.clone()
}
}
/// Create options with a different height.
#[must_use]
pub fn update_height(&self, height: usize) -> Self {
Self {
height: Some(height),
..self.clone()
}
}
/// Create options with updated width and height.
#[must_use]
pub fn update_dimensions(&self, width: usize, height: usize) -> Self {
Self {
size: ConsoleDimensions { width, height },
max_width: width,
max_height: height,
height: Some(height),
..self.clone()
}
}
}
/// Print options for controlling output.
#[derive(Clone, Default)]
pub struct PrintOptions {
/// String to separate multiple objects.
pub sep: String,
/// String to append at end.
pub end: String,
/// Apply style to output.
pub style: Option<Style>,
/// Override justification.
pub justify: Option<JustifyMethod>,
/// Override overflow handling.
pub overflow: Option<OverflowMethod>,
/// Override `no_wrap`.
pub no_wrap: Option<bool>,
/// Suppress newline.
pub no_newline: bool,
/// Parse markup.
pub markup: Option<bool>,
/// Enable/disable highlighting (None = inherit Console setting).
pub highlight: Option<bool>,
/// Override the highlighter used when highlighting is enabled.
pub highlighter: Option<Arc<dyn Highlighter>>,
/// Override width.
pub width: Option<usize>,
/// Crop output to width.
pub crop: bool,
/// Soft wrap at width.
pub soft_wrap: bool,
}
impl PrintOptions {
/// Create new print options with defaults.
#[must_use]
pub fn new() -> Self {
Self {
sep: String::from(" "),
end: String::from("\n"),
..Default::default()
}
}
/// Set markup parsing.
#[must_use]
pub fn with_markup(mut self, markup: bool) -> Self {
self.markup = Some(markup);
self
}
/// Set style.
#[must_use]
pub fn with_style(mut self, style: Style) -> Self {
self.style = Some(style);
self
}
/// Set the separator between objects.
#[must_use]
pub fn with_sep(mut self, sep: impl Into<String>) -> Self {
self.sep = sep.into();
self
}
/// Set the end string appended after output.
#[must_use]
pub fn with_end(mut self, end: impl Into<String>) -> Self {
self.end = end.into();
self
}
/// Override justification.
#[must_use]
pub fn with_justify(mut self, justify: JustifyMethod) -> Self {
self.justify = Some(justify);
self
}
/// Override overflow handling.
#[must_use]
pub fn with_overflow(mut self, overflow: OverflowMethod) -> Self {
self.overflow = Some(overflow);
self
}
/// Override `no_wrap`.
#[must_use]
pub fn with_no_wrap(mut self, no_wrap: bool) -> Self {
self.no_wrap = Some(no_wrap);
self
}
/// Suppress newline at end.
#[must_use]
pub fn with_no_newline(mut self, no_newline: bool) -> Self {
self.no_newline = no_newline;
self
}
/// Enable/disable highlighting.
#[must_use]
pub fn with_highlight(mut self, highlight: bool) -> Self {
self.highlight = Some(highlight);
self
}
/// Override the highlighter for this print call.
#[must_use]
pub fn with_highlighter<H: Highlighter + 'static>(mut self, highlighter: H) -> Self {
self.highlighter = Some(Arc::new(highlighter));
self
}
/// Override width.
#[must_use]
pub fn with_width(mut self, width: usize) -> Self {
self.width = Some(width);
self
}
/// Crop output to width.
#[must_use]
pub fn with_crop(mut self, crop: bool) -> Self {
self.crop = crop;
self
}
/// Soft wrap at width.
#[must_use]
pub fn with_soft_wrap(mut self, soft_wrap: bool) -> Self {
self.soft_wrap = soft_wrap;
self
}
}
impl std::fmt::Debug for PrintOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PrintOptions")
.field("sep", &self.sep)
.field("end", &self.end)
.field("style", &self.style)
.field("justify", &self.justify)
.field("overflow", &self.overflow)
.field("no_wrap", &self.no_wrap)
.field("no_newline", &self.no_newline)
.field("markup", &self.markup)
.field("highlight", &self.highlight)
.field(
"highlighter",
&self.highlighter.as_ref().map(|_| "<Highlighter>"),
)
.field("width", &self.width)
.field("crop", &self.crop)
.field("soft_wrap", &self.soft_wrap)
.finish()
}
}
/// Hook for intercepting rendered segments before output.
pub trait RenderHook: Send + Sync {
fn process(&self, console: &Console, segments: &[Segment<'static>]) -> Vec<Segment<'static>>;
}
/// The main Console for rendering styled output.
///
/// `Console` is the central entry point for all terminal output operations.
/// It handles color detection, terminal dimensions, markup parsing, and
/// ANSI escape code generation.
///
/// # Thread Safety
///
/// `Console` is `Send + Sync` and can be safely shared between threads using
/// `Arc<Console>`. All internal state is protected by mutexes that use poison
/// recovery (see the [`sync`](crate::sync) module).
///
/// When multiple threads print concurrently, their output may interleave at
/// the line level. For strictly ordered output, synchronize at the application
/// level or use a single printing thread.
///
/// # Example
///
/// ```rust,ignore
/// use std::sync::Arc;
/// use std::thread;
/// use rich_rust::Console;
///
/// let console = Arc::new(Console::new());
///
/// let handles: Vec<_> = (0..4).map(|i| {
/// let c = Arc::clone(&console);
/// thread::spawn(move || {
/// c.print(&format!("Hello from thread {i}"));
/// })
/// }).collect();
///
/// for h in handles {
/// h.join().unwrap();
/// }
/// ```
pub struct Console {
/// Color system to use (None = auto-detect).
color_system: Option<ColorSystem>,
/// Force terminal mode.
force_terminal: Option<bool>,
/// Tab expansion size.
tab_size: usize,
/// Buffer output for export.
record: AtomicBool,
/// Parse markup by default.
markup: bool,
/// Enable emoji rendering.
emoji: bool,
/// Enable syntax highlighting.
highlight: bool,
/// Highlighter used when `highlight` is enabled (Python Rich `rich.highlighter` parity).
highlighter: Arc<dyn Highlighter>,
/// Theme stack for named styles (Python Rich parity).
theme_stack: Mutex<ThemeStack>,
/// Override width.
width: Option<usize>,
/// Override height.
height: Option<usize>,
/// Use ASCII-safe box characters.
safe_box: bool,
/// Output stream (defaults to stdout).
file: Mutex<Box<dyn Write + Send>>,
/// Recording buffer.
buffer: Mutex<Vec<Segment<'static>>>,
/// Cached terminal detection.
is_terminal: bool,
/// Detected/configured color system.
detected_color_system: Option<ColorSystem>,
/// Render hooks (Live uses this).
render_hooks: Mutex<Vec<Arc<dyn RenderHook>>>,
/// Active Live stack for nested Live handling.
live_stack: Mutex<Vec<Weak<LiveInner>>>,
}
impl std::fmt::Debug for Console {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Console")
.field("color_system", &self.color_system)
.field("force_terminal", &self.force_terminal)
.field("tab_size", &self.tab_size)
.field("record", &self.record.load(Ordering::Relaxed))
.field("markup", &self.markup)
.field("emoji", &self.emoji)
.field("highlight", &self.highlight)
.field("width", &self.width)
.field("height", &self.height)
.field("safe_box", &self.safe_box)
.field("file", &"<dyn Write>")
.field("buffer_len", &lock_recover(&self.buffer).len())
.field("is_terminal", &self.is_terminal)
.field("detected_color_system", &self.detected_color_system)
.finish_non_exhaustive()
}
}
impl Default for Console {
fn default() -> Self {
Self::new()
}
}
impl Console {
/// Create a new console with default settings.
#[must_use]
pub fn new() -> Self {
let is_terminal = terminal::is_terminal();
let detected_color_system = if is_terminal {
terminal::detect_color_system()
} else {
None
};
Self {
color_system: None,
force_terminal: None,
tab_size: 8,
record: AtomicBool::new(false),
markup: true,
emoji: true,
highlight: true,
highlighter: Arc::new(ReprHighlighter::default()),
theme_stack: Mutex::new(ThemeStack::new(Theme::default())),
width: None,
height: None,
safe_box: false,
file: Mutex::new(Box::new(io::stdout())),
buffer: Mutex::new(Vec::new()),
is_terminal,
detected_color_system,
render_hooks: Mutex::new(Vec::new()),
live_stack: Mutex::new(Vec::new()),
}
}
/// Create a console builder for custom configuration.
#[must_use]
pub fn builder() -> ConsoleBuilder {
ConsoleBuilder::default()
}
/// Convert this Console into a shared reference-counted handle.
#[must_use]
pub fn shared(self) -> Arc<Self> {
Arc::new(self)
}
/// Get the console width.
#[must_use]
pub fn width(&self) -> usize {
self.width.unwrap_or_else(terminal::get_terminal_width)
}
/// Get the console height.
#[must_use]
pub fn height(&self) -> usize {
self.height.unwrap_or_else(terminal::get_terminal_height)
}
/// Get the console dimensions.
#[must_use]
pub fn size(&self) -> ConsoleDimensions {
ConsoleDimensions {
width: self.width(),
height: self.height(),
}
}
/// Check if this console outputs to a terminal.
#[must_use]
pub fn is_terminal(&self) -> bool {
self.force_terminal.unwrap_or(self.is_terminal)
}
/// Terminal detection result without `force_terminal` overrides.
///
/// This is used for behaviors that must not affect non-TTY contexts even if
/// a caller forces terminal rendering (e.g. process-wide stdio redirection).
#[must_use]
pub(crate) const fn is_terminal_detected(&self) -> bool {
self.is_terminal
}
/// Get the color system in use.
#[must_use]
pub fn color_system(&self) -> Option<ColorSystem> {
self.color_system.or(self.detected_color_system)
}
/// Check if Rich-style emoji code replacement is enabled.
#[must_use]
pub const fn emoji(&self) -> bool {
self.emoji
}
/// Check if ASCII-safe box drawing is enabled.
#[must_use]
pub const fn safe_box(&self) -> bool {
self.safe_box
}
/// Get a style by theme name or parse a style definition.
///
/// Mirrors Python Rich `Console.get_style()`:
/// - Check the active theme stack for an exact name match
/// - Fall back to parsing a style definition
///
/// If parsing fails, this returns an empty style.
#[must_use]
pub fn get_style(&self, name: &str) -> Style {
self.try_get_style(name).unwrap_or_else(|_| Style::new())
}
/// Like [`Self::get_style`], but returns an error if the style can't be parsed.
pub fn try_get_style(&self, name: &str) -> Result<Style, StyleParseError> {
{
let stack = lock_recover(&self.theme_stack);
if let Some(style) = stack.get(name) {
return Ok(style.clone());
}
}
Style::parse(name)
}
/// Push a theme on to the theme stack.
pub fn push_theme(&self, theme: Theme, inherit: bool) {
lock_recover(&self.theme_stack).push_theme(theme, inherit);
}
/// Pop the current theme from the theme stack.
pub fn pop_theme(&self) -> Result<(), ThemeStackError> {
lock_recover(&self.theme_stack).pop_theme()
}
/// Use a theme for the duration of the returned guard.
#[must_use]
pub fn use_theme(&self, theme: Theme, inherit: bool) -> ThemeGuard<'_> {
self.push_theme(theme, inherit);
ThemeGuard { console: self }
}
/// Check if colors are enabled.
#[must_use]
pub fn is_color_enabled(&self) -> bool {
self.color_system().is_some()
}
/// Get the tab size.
#[must_use]
pub const fn tab_size(&self) -> usize {
self.tab_size
}
/// Create console options for rendering.
#[must_use]
pub fn options(&self) -> ConsoleOptions {
ConsoleOptions {
size: self.size(),
legacy_windows: false,
min_width: 1,
max_width: self.width(),
is_terminal: self.is_terminal(),
encoding: String::from("utf-8"),
max_height: self.height(),
justify: None,
overflow: None,
no_wrap: None,
highlight: Some(self.highlight),
markup: Some(self.markup),
height: None,
}
}
pub(crate) fn apply_highlighter_to_text(&self, options: &ConsoleOptions, text: &mut Text) {
let highlight_enabled = options.highlight.unwrap_or(self.highlight);
if highlight_enabled {
self.highlighter.highlight(self, text);
}
}
/// Measure a renderable via the measurement protocol (Python Rich `Console.measure` parity).
#[must_use]
pub fn measure(
&self,
renderable: &dyn RichMeasure,
options: Option<ConsoleOptions>,
) -> Measurement {
let options = options.unwrap_or_else(|| self.options());
Measurement::get(self, &options, Some(renderable))
}
/// Check if the terminal is "dumb".
#[must_use]
pub fn is_dumb_terminal(&self) -> bool {
terminal::is_dumb_terminal()
}
/// Check if the console is interactive (TTY and not dumb).
#[must_use]
pub fn is_interactive(&self) -> bool {
self.is_terminal() && !self.is_dumb_terminal()
}
pub(crate) fn push_render_hook(&self, hook: Arc<dyn RenderHook>) {
lock_recover(&self.render_hooks).push(hook);
}
pub(crate) fn pop_render_hook(&self) -> Option<Arc<dyn RenderHook>> {
lock_recover(&self.render_hooks).pop()
}
pub(crate) fn set_live(&self, live: &Arc<LiveInner>) -> bool {
let mut stack = lock_recover(&self.live_stack);
stack.push(Arc::downgrade(live));
stack.len() == 1
}
pub(crate) fn clear_live(&self) {
let mut stack = lock_recover(&self.live_stack);
if !stack.is_empty() {
stack.pop();
}
}
pub(crate) fn live_stack_snapshot(&self) -> Vec<Arc<LiveInner>> {
let mut stack = lock_recover(&self.live_stack);
stack.retain(|entry| entry.strong_count() > 0);
let mut result = Vec::new();
for entry in stack.iter() {
if let Some(live) = entry.upgrade() {
result.push(live);
}
}
result
}
pub(crate) fn write_control_codes(&self, control_codes: Vec<ControlCode>) -> io::Result<()> {
if control_codes.is_empty() {
return Ok(());
}
let segment = Segment::control(control_codes);
let mut file = lock_recover(&self.file);
self.write_segments_raw(&mut *file, &[segment])
}
pub(crate) fn swap_file(&self, writer: Box<dyn Write + Send>) -> Box<dyn Write + Send> {
std::mem::replace(&mut *lock_recover(&self.file), writer)
}
/// Show or hide the cursor.
pub fn show_cursor(&self, show: bool) -> io::Result<()> {
let control = if show {
ControlCode::new(ControlType::ShowCursor)
} else {
ControlCode::new(ControlType::HideCursor)
};
self.write_control_codes(vec![control])
}
/// Enable or disable the alternate screen buffer.
pub fn set_alt_screen(&self, enable: bool) -> io::Result<()> {
let control = if enable {
ControlCode::new(ControlType::EnableAltScreen)
} else {
ControlCode::new(ControlType::DisableAltScreen)
};
self.write_control_codes(vec![control])
}
/// Enable recording mode.
///
/// All subsequent console output will be captured to an internal buffer
/// until [`end_capture`](Self::end_capture) is called.
pub fn begin_capture(&self) {
self.record.store(true, Ordering::Relaxed);
lock_recover(&self.buffer).clear();
}
/// End recording and return captured segments.
///
/// Returns all segments captured since [`begin_capture`](Self::begin_capture)
/// was called, and clears the internal buffer.
pub fn end_capture(&self) -> Vec<Segment<'static>> {
self.record.store(false, Ordering::Relaxed);
std::mem::take(&mut *lock_recover(&self.buffer))
}
/// Print styled text to the console.
///
/// # Examples
///
/// ```ignore
/// use rich_rust::Console;
///
/// let console = Console::new();
/// console.print("[bold red]Hello[/] World!");
/// ```
pub fn print(&self, content: &str) {
self.print_with_options(content, &PrintOptions::new().with_markup(self.markup));
}
/// Print a prepared Text object.
pub fn print_text(&self, text: &Text) {
let mut file = lock_recover(&self.file);
let _ = self.print_text_to(&mut *file, text);
}
/// Print a prepared Text object to a specific writer.
pub fn print_text_to<W: Write>(&self, writer: &mut W, text: &Text) -> io::Result<()> {
let segments: Vec<Segment<'static>> = text
.render(&text.end)
.into_iter()
.map(Segment::into_owned)
.collect();
let segments = self.apply_render_hooks(segments);
self.write_segments_raw(writer, &segments)
}
/// Print prepared segments.
pub fn print_segments(&self, segments: &[Segment<'_>]) {
let mut file = lock_recover(&self.file);
let _ = self.print_segments_to(&mut *file, segments);
}
/// Print prepared segments to a specific writer.
pub fn print_segments_to<W: Write>(
&self,
writer: &mut W,
segments: &[Segment<'_>],
) -> io::Result<()> {
let owned: Vec<Segment<'static>> =
segments.iter().cloned().map(Segment::into_owned).collect();
let processed = self.apply_render_hooks(owned);
self.write_segments_raw(writer, &processed)
}
/// Print any object implementing the Renderable trait.
pub fn print_renderable(&self, renderable: &impl Renderable) {
let options = self.options();
let segments = renderable.render(self, &options);
self.print_segments(&segments);
}
fn render_rich_cast_segments(
&self,
value: &dyn RichCast,
options: &PrintOptions,
) -> Vec<Segment<'static>> {
match crate::protocol::rich_cast(value) {
RichCastOutput::Str(text) => self.render_str_segments(&text, options),
RichCastOutput::Renderable(renderable) => {
let options = self.options();
renderable
.render(self, &options)
.into_iter()
.map(Segment::into_owned)
.collect()
}
RichCastOutput::Castable(renderable) => {
let options = self.options();
renderable
.render(self, &options)
.into_iter()
.map(Segment::into_owned)
.collect()
}
}
}
/// Print a value via the protocol casting hook (Python Rich `rich.protocol.rich_cast` parity).
pub fn print_cast(&self, value: &dyn RichCast) {
self.print_cast_with_options(value, &PrintOptions::new().with_markup(self.markup));
}
/// Print a castable value with custom options (string options apply when the cast yields a string).
pub fn print_cast_with_options(&self, value: &dyn RichCast, options: &PrintOptions) {
let mut file = lock_recover(&self.file);
let _ = self.print_cast_to(&mut *file, value, options);
}
/// Print a castable value to a specific writer.
pub fn print_cast_to<W: Write>(
&self,
writer: &mut W,
value: &dyn RichCast,
options: &PrintOptions,
) -> io::Result<()> {
let segments = self.render_rich_cast_segments(value, options);
let segments = self.apply_render_hooks(segments);
self.write_segments_raw(writer, &segments)
}
/// Print an exception / traceback renderable.
///
/// This is a convenience wrapper mirroring Python Rich's `Console.print_exception`.
pub fn print_exception(&self, traceback: &crate::renderables::Traceback) {
self.print_renderable(traceback);
}
/// Print with custom options.
pub fn print_with_options(&self, content: &str, options: &PrintOptions) {
let mut file = lock_recover(&self.file);
// Keep `Console::print_*` infallible (matches Rich's ergonomics). If callers need
// I/O error handling they can use `Console::print_to(...)` directly.
let _ = self.print_to(&mut *file, content, options);
}
/// Export rendered text (no ANSI) using default print options.
#[must_use]
pub fn export_text(&self, content: &str) -> String {
self.export_text_with_options(content, &PrintOptions::new().with_markup(self.markup))
}
/// Export rendered text (no ANSI) using custom print options.
#[must_use]
pub fn export_text_with_options(&self, content: &str, options: &PrintOptions) -> String {
let segments = self.render_str_segments(content, options);
Self::segments_to_plain(&segments)
}
/// Export a castable value to plain text (no ANSI).
#[must_use]
pub fn export_cast_text(&self, value: &dyn RichCast) -> String {
self.export_cast_text_with_options(value, &PrintOptions::new().with_markup(self.markup))
}
/// Export a castable value to plain text (no ANSI) using custom print options.
#[must_use]
pub fn export_cast_text_with_options(
&self,
value: &dyn RichCast,
options: &PrintOptions,
) -> String {
let segments = self.render_rich_cast_segments(value, options);
Self::segments_to_plain(&segments)
}
/// Export a renderable to plain text (no ANSI).
#[must_use]
pub fn export_renderable_text(&self, renderable: &impl Renderable) -> String {
let options = self.options();
let segments = renderable.render(self, &options);
Self::segments_to_plain(&segments)
}
/// Export recorded output to HTML.
#[must_use]
pub fn export_html(&self, clear: bool) -> String {
self.export_html_with_options(&ExportHtmlOptions {
clear,
..ExportHtmlOptions::default()
})
}
/// Export recorded output to SVG.
#[must_use]
pub fn export_svg(&self, clear: bool) -> String {
self.export_svg_with_options(&ExportSvgOptions {
clear,
..ExportSvgOptions::default()
})
}
/// Export recorded output to HTML with Rich-style options.
///
/// Mirrors Python Rich's `Console.export_html(...)` behavior.
#[must_use]
pub fn export_html_with_options(&self, options: &ExportHtmlOptions) -> String {
assert!(
self.record.load(Ordering::Relaxed),
"To export console contents call Console::begin_capture() first"
);
let segments = self.recorded_segments(options.clear);
export_segments_to_html_rich(&segments, options)
}
/// Export recorded output to SVG with Rich-style options.
///
/// Mirrors Python Rich's `Console.export_svg(...)` behavior.
#[must_use]
pub fn export_svg_with_options(&self, options: &ExportSvgOptions) -> String {
assert!(
self.record.load(Ordering::Relaxed),
"To export console contents call Console::begin_capture() first"
);
let segments = self.recorded_segments(options.clear);
export_segments_to_svg_rich(&segments, self.width(), options)
}
/// Print to a specific writer.
pub fn print_to<W: Write>(
&self,
writer: &mut W,
content: &str,
options: &PrintOptions,
) -> io::Result<()> {
let segments = self.render_str_segments(content, options);
let segments = self.apply_render_hooks(segments);
self.write_segments_raw(writer, &segments)
}
fn render_str_segments(&self, content: &str, options: &PrintOptions) -> Vec<Segment<'static>> {
let content = if self.emoji {
emoji::replace(content, None)
} else {
std::borrow::Cow::Borrowed(content)
};
// Parse markup if enabled
let parse_markup = options.markup.unwrap_or(self.markup);
let mut text = if parse_markup {
markup::render_or_plain_with_style_resolver(content.as_ref(), |definition| {
self.get_style(definition)
})
} else {
Text::new(content.as_ref())
};
let highlight_enabled = options.highlight.unwrap_or(self.highlight);
if highlight_enabled {
let highlighter = options.highlighter.as_ref().unwrap_or(&self.highlighter);
highlighter.highlight(self, &mut text);
}
if let Some(justify) = options.justify {
text.justify = justify;
}
if let Some(overflow) = options.overflow {
text.overflow = overflow;
}
if let Some(no_wrap) = options.no_wrap {
text.no_wrap = no_wrap;
}
if options.crop {
text.overflow = OverflowMethod::Crop;
}
// soft_wrap enables wrapping by overriding text's no_wrap setting
if options.soft_wrap {
text.no_wrap = false;
}
let width = options.width.or_else(|| {
if options.justify.is_some()
|| options.overflow.is_some()
|| options.no_wrap.is_some()
|| options.crop
|| options.soft_wrap
{
Some(self.width())
} else {
None
}
});
let end = if options.no_newline { "" } else { &options.end };
let mut segments: Vec<Segment<'static>> = if let Some(width) = width {
let mut rendered = Vec::new();
let lines = if text.no_wrap {
text.split_lines()
} else {
text.wrap(width)
};
let last_index = lines.len().saturating_sub(1);
let justify = match text.justify {
JustifyMethod::Default => JustifyMethod::Left,
other => other,
};
for (index, mut line) in lines.into_iter().enumerate() {
if text.no_wrap && line.cell_len() > width {
line.truncate(width, line.overflow, false);
}
if matches!(
justify,
JustifyMethod::Center | JustifyMethod::Right | JustifyMethod::Full
) && line.cell_len() < width
{
line.pad(width, justify);
}
let line_end = if index == last_index { end } else { "\n" };
rendered.extend(line.render(line_end).into_iter().map(Segment::into_owned));
}
rendered
} else {
text.render(end)
.into_iter()
.map(Segment::into_owned)
.collect()
};
// Apply any overall style
if let Some(ref style) = options.style {
for segment in &mut segments {
if !segment.is_control() {
segment.style = Some(match segment.style {
Some(ref s) => style.combine(s),
None => style.clone(),
});
}
}
}
segments
}
fn segments_to_plain(segments: &[Segment<'_>]) -> String {
let capacity: usize = segments
.iter()
.filter(|segment| !segment.is_control())
.map(|segment| segment.text.len())
.sum();
let mut output = String::with_capacity(capacity);
for segment in segments {
if !segment.is_control() {
output.push_str(segment.text.as_ref());
}
}
output
}
fn recorded_segments(&self, clear: bool) -> Vec<Segment<'static>> {
let mut buffer = lock_recover(&self.buffer);
let segments = buffer.clone();
if clear {
buffer.clear();
}
segments
}
fn apply_render_hooks(&self, segments: Vec<Segment<'static>>) -> Vec<Segment<'static>> {
let hooks = lock_recover(&self.render_hooks).clone();
if hooks.is_empty() {
return segments;
}
let mut current = segments;
for hook in hooks {
current = hook.process(self, ¤t);
}
current
}
/// Write segments to a writer without invoking render hooks.
fn write_segments_raw<W: Write>(
&self,
writer: &mut W,
segments: &[Segment<'_>],
) -> io::Result<()> {
if self.record.load(Ordering::Relaxed) {
lock_recover(&self.buffer).extend(segments.iter().cloned().map(Segment::into_owned));
}
let color_system = self.color_system();
for segment in segments {
if segment.is_control() {
self.write_control_segment(writer, segment)?;
continue;
}
// Get ANSI codes for style
let ansi_codes;
let (prefix, suffix) = if let Some(ref style) = segment.style {
if let Some(cs) = color_system {
ansi_codes = style.render_ansi(cs);
(&ansi_codes.0, &ansi_codes.1)
} else {
static EMPTY: (String, String) = (String::new(), String::new());
(&EMPTY.0, &EMPTY.1)
}
} else {
static EMPTY: (String, String) = (String::new(), String::new());
(&EMPTY.0, &EMPTY.1)
};
// Write styled text
write!(writer, "{prefix}{}{suffix}", segment.text)?;
}
writer.flush()
}
fn write_control_segment<W: Write>(
&self,
writer: &mut W,
segment: &Segment<'_>,
) -> io::Result<()> {
let Some(ref controls) = segment.control else {
return Ok(());
};
for control in controls {
match control.control_type {
crate::segment::ControlType::Bell => {
write!(writer, "\x07")?;
}
crate::segment::ControlType::CarriageReturn => {
write!(writer, "\r")?;
}
crate::segment::ControlType::Home => {
write!(writer, "\x1b[H")?;
}
crate::segment::ControlType::Clear => {
write!(writer, "\x1b[2J")?;
}
crate::segment::ControlType::ShowCursor => {
write!(writer, "\x1b[?25h")?;
}
crate::segment::ControlType::HideCursor => {
write!(writer, "\x1b[?25l")?;
}
crate::segment::ControlType::EnableAltScreen => {
write!(writer, "\x1b[?1049h")?;
}
crate::segment::ControlType::DisableAltScreen => {
write!(writer, "\x1b[?1049l")?;
}
crate::segment::ControlType::CursorUp => {
let n = control_param(&control.params, 0, 1);
write!(writer, "\x1b[{n}A")?;
}
crate::segment::ControlType::CursorDown => {
let n = control_param(&control.params, 0, 1);
write!(writer, "\x1b[{n}B")?;
}
crate::segment::ControlType::CursorForward => {
let n = control_param(&control.params, 0, 1);
write!(writer, "\x1b[{n}C")?;
}
crate::segment::ControlType::CursorBackward => {
let n = control_param(&control.params, 0, 1);
write!(writer, "\x1b[{n}D")?;
}
crate::segment::ControlType::CursorMoveToColumn => {
// Python Rich expects 0-based columns in ControlCode parameters and
// formats with +1 (terminal control sequences are 1-based).
let column0 = control_param(&control.params, 0, 0);
write!(writer, "\x1b[{}G", column0 + 1)?;
}
crate::segment::ControlType::CursorMoveTo => {
// Python Rich stores (x, y) 0-based and formats as (y+1; x+1).
let x0 = control_param(&control.params, 0, 0);
let y0 = control_param(&control.params, 1, 0);
write!(writer, "\x1b[{};{}H", y0 + 1, x0 + 1)?;
}
crate::segment::ControlType::EraseInLine => {
let mode = erase_in_line_mode(&control.params);
write!(writer, "\x1b[{mode}K")?;
}
crate::segment::ControlType::SetWindowTitle => {
let title = control_title(segment, control);
write!(writer, "\x1b]0;{title}\x07")?;
}
}
}
Ok(())
}
/// Print a blank line.
pub fn line(&self) {
let mut file = lock_recover(&self.file);
let _ = writeln!(file);
}
/// Print a rule (horizontal line).
pub fn rule(&self, title: Option<&str>) {
let width = self.width();
let line_char = if self.safe_box { '-' } else { '\u{2500}' };
let mut file = lock_recover(&self.file);
if let Some(title) = title {
// Ensure title fits within width, accounting for 2 spaces padding
let max_title_width = width.saturating_sub(2);
let title_len = crate::cells::cell_len(title);
let display_title = if title_len > max_title_width {
let mut t = Text::new(title);
t.truncate(max_title_width, OverflowMethod::Ellipsis, false);
t.plain().to_string()
} else {
title.to_string()
};
let display_len = crate::cells::cell_len(&display_title);
let available = width.saturating_sub(display_len + 2);
let left_pad = available / 2;
let right_pad = available - left_pad;
let left = line_char.to_string().repeat(left_pad);
let right = line_char.to_string().repeat(right_pad);
let _ = writeln!(file, "{left} {display_title} {right}");
} else {
let _ = writeln!(file, "{}", line_char.to_string().repeat(width));
}
}
/// Clear the screen.
pub fn clear(&self) {
let mut file = lock_recover(&self.file);
let _ = terminal::control::clear_screen(&mut *file);
}
/// Clear the current line.
pub fn clear_line(&self) {
let mut file = lock_recover(&self.file);
let _ = terminal::control::clear_line(&mut *file);
}
/// Set the terminal title.
pub fn set_title(&self, title: &str) {
let mut file = lock_recover(&self.file);
let _ = terminal::control::set_title(&mut *file, title);
}
/// Ring the terminal bell.
pub fn bell(&self) {
let mut file = lock_recover(&self.file);
let _ = terminal::control::bell(&mut *file);
}
/// Print text without parsing markup.
pub fn print_plain(&self, content: &str) {
self.print_with_options(content, &PrintOptions::new().with_markup(false));
}
/// Print a styled message.
pub fn print_styled(&self, content: &str, style: Style) {
self.print_with_options(
content,
&PrintOptions::new()
.with_markup(self.markup)
.with_style(style),
);
}
/// Print a log message with a level indicator.
///
/// This is a simple version that just shows the level prefix and message.
/// For timestamps and file/line info, use [`log_with_options`](Self::log_with_options).
///
/// # Examples
///
/// ```rust,ignore
/// use rich_rust::console::{Console, LogLevel};
///
/// let console = Console::new();
/// console.log("Starting server", LogLevel::Info);
/// console.log("Something went wrong", LogLevel::Error);
/// ```
pub fn log(&self, message: &str, level: LogLevel) {
self.log_with_options(message, level, &LogOptions::new());
}
/// Print a log message with a level indicator, timestamp, and optional file/line info.
///
/// # Examples
///
/// ```rust,ignore
/// use rich_rust::console::{Console, LogLevel, LogOptions};
///
/// let console = Console::new();
///
/// // With timestamp
/// let opts = LogOptions::new().with_timestamp(true);
/// console.log_with_options("Server started", LogLevel::Info, &opts);
/// // Output: [12:34:56] [INFO] Server started
///
/// // With timestamp and file/line
/// let opts = LogOptions::new()
/// .with_timestamp(true)
/// .with_path("src/main.rs", 42);
/// console.log_with_options("Debug info", LogLevel::Debug, &opts);
/// // Output: [12:34:56] src/main.rs:42 [DEBUG] Debug info
/// ```
pub fn log_with_options(&self, message: &str, level: LogLevel, options: &LogOptions) {
let (level_prefix, level_style) = match level {
LogLevel::Debug => ("[DEBUG]", Style::parse("cyan").unwrap_or_default()),
LogLevel::Info => ("[INFO]", Style::parse("green").unwrap_or_default()),
LogLevel::Warning => ("[WARNING]", Style::parse("yellow").unwrap_or_default()),
LogLevel::Error => ("[ERROR]", Style::parse("bold red").unwrap_or_default()),
};
{
let mut file = lock_recover(&self.file);
// Print timestamp if enabled
if options.show_timestamp {
let timestamp = Self::format_timestamp(options.timestamp_format.as_deref());
let ts_style = Style::parse("dim").unwrap_or_default();
let _ = self.print_to(
&mut *file,
×tamp,
&PrintOptions::new().with_markup(false).with_style(ts_style),
);
let _ = write!(file, " ");
}
// Print file/line info if provided
if options.file_path.is_some() || options.line_number.is_some() {
let path_style = Style::parse("magenta").unwrap_or_default();
let path_info = match (&options.file_path, options.line_number) {
(Some(path), Some(line)) => format!("{path}:{line}"),
(Some(path), None) => path.clone(),
(None, Some(line)) => format!(":{line}"),
(None, None) => String::new(),
};
if !path_info.is_empty() {
let _ = self.print_to(
&mut *file,
&path_info,
&PrintOptions::new()
.with_markup(false)
.with_style(path_style),
);
let _ = write!(file, " ");
}
}
// Print level prefix if enabled
if options.show_level {
let _ = self.print_to(
&mut *file,
level_prefix,
&PrintOptions::new()
.with_markup(false)
.with_style(level_style),
);
let _ = write!(file, " ");
}
// Print the message
let _ = self.print_to(
&mut *file,
message,
&PrintOptions::new().with_markup(self.markup),
);
}
}
/// Format the current time as a timestamp string.
fn format_timestamp(format: Option<&str>) -> String {
// Prefer local time for parity with typical "console logger" expectations, but
// fall back to UTC when local offset can't be determined (e.g., sandboxed envs).
let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
match format {
None => format!(
"[{:02}:{:02}:{:02}]",
now.hour(),
now.minute(),
now.second()
),
Some(fmt) => Self::format_timestamp_strftime_subset(&now, fmt),
}
}
// Intentionally supports a small, stable subset of strftime:
// %Y %m %d %H %M %S and %%.
fn format_timestamp_strftime_subset(now: &OffsetDateTime, fmt: &str) -> String {
let mut out = String::with_capacity(fmt.len().saturating_add(8));
let mut it = fmt.chars();
while let Some(ch) = it.next() {
if ch != '%' {
out.push(ch);
continue;
}
let Some(code) = it.next() else {
out.push('%');
break;
};
match code {
'%' => out.push('%'),
'H' => {
let _ = write!(out, "{:02}", now.hour());
}
'M' => {
let _ = write!(out, "{:02}", now.minute());
}
'S' => {
let _ = write!(out, "{:02}", now.second());
}
'Y' => {
let _ = write!(out, "{:04}", now.year());
}
'm' => {
// time::Month implements `From<Month> for u8`.
let _ = write!(out, "{:02}", u8::from(now.month()));
}
'd' => {
let _ = write!(out, "{:02}", now.day());
}
other => {
// Preserve unknown tokens literally to avoid surprising callers.
out.push('%');
out.push(other);
}
}
}
out
}
}
fn control_param(params: &[i32], index: usize, default: i32) -> i32 {
params
.get(index)
.copied()
.filter(|value| *value > 0)
.unwrap_or(default)
}
fn erase_in_line_mode(params: &[i32]) -> i32 {
if let Some(value) = params.first().copied()
&& (0..=2).contains(&value)
{
return value;
}
2
}
fn control_title(segment: &Segment<'_>, control: &crate::segment::ControlCode) -> String {
let raw_title = if !segment.text.is_empty() {
segment.text.to_string()
} else if !control.params.is_empty() {
let mut title = String::with_capacity(control.params.len());
for param in &control.params {
if let Ok(byte) = u8::try_from(*param) {
title.push(byte as char);
}
}
title
} else {
String::new()
};
// Sanitize title to prevent terminal injection:
// Remove control characters that could break or escape the OSC sequence
raw_title
.chars()
.filter(|c| {
// Allow printable characters only, excluding control chars
// BEL (\x07) terminates OSC, ESC (\x1b) starts new sequences
!c.is_control()
})
.collect()
}
// ============================================================================
// HTML/SVG Export (Python Rich parity)
// ============================================================================
/// Default HTML export template (Rich 13.9.4).
pub const CONSOLE_HTML_FORMAT: &str = "<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"UTF-8\">\n<style>\n{stylesheet}\nbody {\n color: {foreground};\n background-color: {background};\n}\n</style>\n</head>\n<body>\n <pre style=\"font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\"><code style=\"font-family:inherit\">{code}</code></pre>\n</body>\n</html>\n";
/// Default SVG export template (Rich 13.9.4).
pub const CONSOLE_SVG_FORMAT: &str = "<svg class=\"rich-terminal\" viewBox=\"0 0 {width} {height}\" xmlns=\"http://www.w3.org/2000/svg\">\n <!-- Generated with Rich https://www.textualize.io -->\n <style>\n\n @font-face {\n font-family: \"Fira Code\";\n src: local(\"FiraCode-Regular\"),\n url(\"https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2\") format(\"woff2\"),\n url(\"https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff\") format(\"woff\");\n font-style: normal;\n font-weight: 400;\n }\n @font-face {\n font-family: \"Fira Code\";\n src: local(\"FiraCode-Bold\"),\n url(\"https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2\") format(\"woff2\"),\n url(\"https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff\") format(\"woff\");\n font-style: bold;\n font-weight: 700;\n }\n\n .{unique_id}-matrix {\n font-family: Fira Code, monospace;\n font-size: {char_height}px;\n line-height: {line_height}px;\n font-variant-east-asian: full-width;\n }\n\n .{unique_id}-title {\n font-size: 18px;\n font-weight: bold;\n font-family: arial;\n }\n\n {styles}\n </style>\n\n <defs>\n <clipPath id=\"{unique_id}-clip-terminal\">\n <rect x=\"0\" y=\"0\" width=\"{terminal_width}\" height=\"{terminal_height}\" />\n </clipPath>\n {lines}\n </defs>\n\n {chrome}\n <g transform=\"translate({terminal_x}, {terminal_y})\" clip-path=\"url(#{unique_id}-clip-terminal)\">\n {backgrounds}\n <g class=\"{unique_id}-matrix\">\n {matrix}\n </g>\n </g>\n</svg>\n";
/// Options for controlling HTML export.
#[derive(Debug, Clone)]
pub struct ExportHtmlOptions {
pub theme: TerminalTheme,
pub clear: bool,
/// Optional template override. If `None`, uses [`CONSOLE_HTML_FORMAT`].
pub code_format: Option<String>,
pub inline_styles: bool,
}
impl Default for ExportHtmlOptions {
fn default() -> Self {
Self {
theme: DEFAULT_TERMINAL_THEME,
clear: true,
code_format: None,
inline_styles: false,
}
}
}
/// Options for controlling SVG export.
#[derive(Debug, Clone)]
pub struct ExportSvgOptions {
pub title: String,
pub theme: TerminalTheme,
pub clear: bool,
/// Optional template override. If `None`, uses [`CONSOLE_SVG_FORMAT`].
pub code_format: Option<String>,
pub font_aspect_ratio: f64,
pub unique_id: Option<String>,
}
impl Default for ExportSvgOptions {
fn default() -> Self {
Self {
title: "Rich".to_string(),
theme: SVG_EXPORT_THEME,
clear: true,
code_format: None,
font_aspect_ratio: 0.61,
unique_id: None,
}
}
}
fn export_segments_to_html_rich(segments: &[Segment<'_>], options: &ExportHtmlOptions) -> String {
let theme = options.theme;
let render_code_format = options
.code_format
.as_deref()
.unwrap_or(CONSOLE_HTML_FORMAT);
let simplified = crate::segment::simplify(segments.iter().cloned());
let mut fragments: Vec<String> = Vec::new();
let mut stylesheet = String::new();
if options.inline_styles {
for segment in simplified {
if segment.is_control() {
continue;
}
let mut text = escape_html_rich(segment.text.as_ref());
if let Some(style) = &segment.style {
let rule = style.get_html_style(theme);
if let Some(link) = &style.link {
text = format!("<a href=\"{link}\">{text}</a>");
}
if !rule.is_empty() {
text = format!("<span style=\"{rule}\">{text}</span>");
}
}
fragments.push(text);
}
} else {
let mut rules_to_no: HashMap<String, usize> = HashMap::new();
let mut rules_in_order: Vec<String> = Vec::new();
let mut get_no = |rule: &str| -> usize {
if let Some(n) = rules_to_no.get(rule) {
*n
} else {
let n = rules_in_order.len() + 1;
rules_in_order.push(rule.to_string());
rules_to_no.insert(rule.to_string(), n);
n
}
};
for segment in simplified {
if segment.is_control() {
continue;
}
let mut text = escape_html_rich(segment.text.as_ref());
if let Some(style) = &segment.style {
let rule = style.get_html_style(theme);
let style_no = get_no(&rule);
if let Some(link) = &style.link {
text = format!("<a class=\"r{style_no}\" href=\"{link}\">{text}</a>");
} else {
text = format!("<span class=\"r{style_no}\">{text}</span>");
}
}
fragments.push(text);
}
let mut stylesheet_rules: Vec<String> = Vec::new();
for (idx, rule) in rules_in_order.iter().enumerate() {
let style_no = idx + 1;
if !rule.is_empty() {
stylesheet_rules.push(format!(".r{style_no} {{{rule}}}"));
}
}
stylesheet = stylesheet_rules.join("\n");
}
let code = fragments.join("");
let foreground = theme.foreground_color.hex();
let background = theme.background_color.hex();
apply_template(
render_code_format,
&[
("code", &code),
("stylesheet", &stylesheet),
("foreground", &foreground),
("background", &background),
],
)
}
#[expect(
clippy::cast_precision_loss,
reason = "SVG export uses f64 coordinates; console widths/heights are small in practice"
)]
fn export_segments_to_svg_rich(
segments: &[Segment<'_>],
console_width: usize,
options: &ExportSvgOptions,
) -> String {
use crate::cells::cell_len;
let theme = options.theme;
let code_format = options.code_format.as_deref().unwrap_or(CONSOLE_SVG_FORMAT);
let width = console_width;
let char_height = 20.0_f64;
let char_width = char_height * options.font_aspect_ratio;
let line_height = char_height * 1.22;
let margin_top = 1.0_f64;
let margin_right = 1.0_f64;
let margin_bottom = 1.0_f64;
let margin_left = 1.0_f64;
let padding_top = 40.0_f64;
let padding_right = 8.0_f64;
let padding_bottom = 8.0_f64;
let padding_left = 8.0_f64;
let padding_width = padding_left + padding_right;
let padding_height = padding_top + padding_bottom;
let margin_width = margin_left + margin_right;
let margin_height = margin_top + margin_bottom;
let mut style_cache: HashMap<Style, String> = HashMap::new();
let mut get_svg_style = |style: &Style| -> String {
if let Some(cached) = style_cache.get(style) {
return cached.clone();
}
let css = style.get_svg_style(theme);
style_cache.insert(style.clone(), css.clone());
css
};
let mut text_backgrounds: Vec<String> = Vec::new();
let mut text_group: Vec<String> = Vec::new();
let mut classes_to_no: HashMap<String, usize> = HashMap::new();
let mut classes_in_order: Vec<String> = Vec::new();
let mut get_class_no = |rules: &str| -> usize {
if let Some(n) = classes_to_no.get(rules) {
*n
} else {
let n = classes_in_order.len() + 1;
classes_in_order.push(rules.to_string());
classes_to_no.insert(rules.to_string(), n);
n
}
};
let escape_text = |text: &str| -> String { escape_html_rich(text).replace(' ', " ") };
let segments: Vec<Segment<'static>> = segments
.iter()
.cloned()
.map(Segment::into_owned)
.filter(|seg| !seg.is_control())
.collect();
let unique_id = options.unique_id.clone().unwrap_or_else(|| {
let mut repr = String::new();
for seg in &segments {
if seg.is_control() {
continue;
}
let _ = FmtWrite::write_fmt(
&mut repr,
format_args!(
"Segment(text={:?},style={:?},control={:?})",
seg.text, seg.style, seg.control
),
);
}
repr.push_str(&options.title);
let checksum = adler32(repr.as_bytes());
format!("terminal-{checksum}")
});
let mut y_last = 0usize;
let mut lines = crate::segment::split_lines(segments.into_iter());
lines = lines
.into_iter()
.map(|line| crate::segment::adjust_line_length(line, width, None, false))
.collect();
let default_style = Style::default();
for (y, line) in lines.iter().enumerate() {
y_last = y;
let mut x_cells = 0usize;
for segment in line {
if segment.is_control() {
continue;
}
let text = segment.text.as_ref();
let style = segment.style.as_ref().unwrap_or(&default_style);
let rules = get_svg_style(style);
let class_no = get_class_no(&rules);
let class_name = format!("r{class_no}");
let (has_background, background_hex) = if style.attributes.contains(Attributes::REVERSE)
{
let bg = match &style.color {
None => theme.foreground_color,
Some(c) => c.get_truecolor_with_theme(theme, true),
};
(true, bg.hex())
} else {
let has_bg = style.bgcolor.as_ref().is_some_and(|c| !c.is_default());
let bg = match &style.bgcolor {
None => theme.background_color,
Some(c) => c.get_truecolor_with_theme(theme, false),
};
(has_bg, bg.hex())
};
let text_length = cell_len(text);
if has_background {
text_backgrounds.push(format!(
"<rect fill=\"{background_hex}\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" shape-rendering=\"crispEdges\"/>",
(x_cells as f64) * char_width,
(y as f64) * line_height + 1.5,
char_width * (text_length as f64),
line_height + 0.25
));
}
let all_spaces = text.chars().all(|ch| ch == ' ');
if !all_spaces {
let text_len_chars = text.chars().count();
text_group.push(format!(
"<text class=\"{unique_id}-{class_name}\" x=\"{}\" y=\"{}\" textLength=\"{}\" clip-path=\"url(#{unique_id}-line-{y})\">{}</text>",
(x_cells as f64) * char_width,
(y as f64) * line_height + char_height,
char_width * (text_len_chars as f64),
escape_text(text)
));
}
x_cells = x_cells.saturating_add(cell_len(text));
}
}
let mut lines_defs = String::new();
if y_last > 0 {
for line_no in 0..y_last {
let offset = (line_no as f64) * line_height + 1.5;
let _ = FmtWrite::write_fmt(
&mut lines_defs,
format_args!(
"<clipPath id=\"{unique_id}-line-{line_no}\">\n <rect x=\"0\" y=\"{offset}\" width=\"{}\" height=\"{}\"/>\n </clipPath>",
char_width * (width as f64),
line_height + 0.25
),
);
}
}
let mut styles = String::new();
for (idx, css) in classes_in_order.iter().enumerate() {
let rule_no = idx + 1;
let _ = FmtWrite::write_fmt(
&mut styles,
format_args!(".{unique_id}-r{rule_no} {{ {css} }}\n"),
);
}
let backgrounds = text_backgrounds.join("");
let matrix = text_group.join("");
let outer_terminal_width = ((width as f64) * char_width + padding_width).ceil();
let outer_terminal_height = ((y_last as f64) + 1.0) * line_height + padding_height;
let mut chrome = format!(
"<rect fill=\"{}\" stroke=\"rgba(255,255,255,0.35)\" stroke-width=\"1\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" rx=\"8\"/>",
theme.background_color.hex(),
margin_left,
margin_top,
outer_terminal_width,
outer_terminal_height
);
if !options.title.is_empty() {
let title_fill = theme.foreground_color.hex();
let title_x = outer_terminal_width / 2.0;
let title_y = margin_top + char_height + 6.0;
let _ = FmtWrite::write_fmt(
&mut chrome,
format_args!(
"<text class=\"{unique_id}-title\" fill=\"{title_fill}\" text-anchor=\"middle\" x=\"{title_x}\" y=\"{title_y}\">{}</text>",
escape_text(&options.title)
),
);
}
chrome.push_str(
"\n <g transform=\"translate(26,22)\">\n <circle cx=\"0\" cy=\"0\" r=\"7\" fill=\"#ff5f57\"/>\n <circle cx=\"22\" cy=\"0\" r=\"7\" fill=\"#febc2e\"/>\n <circle cx=\"44\" cy=\"0\" r=\"7\" fill=\"#28c840\"/>\n </g>\n ",
);
let char_width_s = char_width.to_string();
let char_height_s = char_height.to_string();
let line_height_s = line_height.to_string();
let terminal_width_s = (char_width * (width as f64) - 1.0).to_string();
let terminal_height_s = (((y_last as f64) + 1.0) * line_height - 1.0).to_string();
let width_s = (outer_terminal_width + margin_width).to_string();
let height_s = (outer_terminal_height + margin_height).to_string();
let terminal_translate_x = (margin_left + padding_left).to_string();
let terminal_translate_y = (margin_top + padding_top).to_string();
apply_template(
code_format,
&[
("unique_id", &unique_id),
("char_width", &char_width_s),
("char_height", &char_height_s),
("line_height", &line_height_s),
("terminal_width", &terminal_width_s),
("terminal_height", &terminal_height_s),
("width", &width_s),
("height", &height_s),
("terminal_x", &terminal_translate_x),
("terminal_y", &terminal_translate_y),
("styles", &styles),
("chrome", &chrome),
("backgrounds", &backgrounds),
("matrix", &matrix),
("lines", &lines_defs),
],
)
}
fn apply_template(template: &str, vars: &[(&str, &str)]) -> String {
let mut out = template.to_string();
for (key, value) in vars {
out = out.replace(&format!("{{{key}}}"), value);
}
out
}
fn escape_html_rich(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'"' => escaped.push_str("""),
_ => escaped.push(ch),
}
}
escaped
}
fn adler32(bytes: &[u8]) -> u32 {
const MOD_ADLER: u32 = 65521;
let mut a: u32 = 1;
let mut b: u32 = 0;
for &byte in bytes {
a = (a + u32::from(byte)) % MOD_ADLER;
b = (b + a) % MOD_ADLER;
}
(b << 16) | a
}
/// Log level for `console.log()`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Debug,
Info,
Warning,
Error,
}
/// Options for controlling log output format.
///
/// # Examples
///
/// ```rust,ignore
/// use rich_rust::console::{Console, LogLevel, LogOptions};
///
/// let console = Console::new();
///
/// // Log with timestamp
/// let opts = LogOptions::new().with_timestamp(true);
/// console.log_with_options("Something happened", LogLevel::Info, &opts);
///
/// // Log with file/line info
/// let opts = LogOptions::new()
/// .with_timestamp(true)
/// .with_path("src/main.rs", 42);
/// console.log_with_options("Debug info", LogLevel::Debug, &opts);
/// ```
#[derive(Debug, Clone)]
pub struct LogOptions {
/// Whether to show a timestamp.
pub show_timestamp: bool,
/// Custom timestamp format (strftime-like subset).
///
/// Supported codes: `%Y` `%m` `%d` `%H` `%M` `%S` and `%%`.
/// Unknown codes are preserved literally.
///
/// If None, uses default format: `"[HH:MM:SS]"`.
pub timestamp_format: Option<String>,
/// File path (e.g., "src/main.rs").
pub file_path: Option<String>,
/// Line number within the file.
pub line_number: Option<u32>,
/// Whether to show the log level prefix.
pub show_level: bool,
/// Whether to highlight keywords in the message.
pub highlight: bool,
}
impl Default for LogOptions {
fn default() -> Self {
Self::new()
}
}
impl LogOptions {
/// Create new log options with default values.
#[must_use]
pub fn new() -> Self {
Self {
show_timestamp: false,
timestamp_format: None,
file_path: None,
line_number: None,
show_level: true,
highlight: false,
}
}
/// Enable or disable timestamp display.
#[must_use]
pub fn with_timestamp(mut self, show: bool) -> Self {
self.show_timestamp = show;
self
}
/// Set a custom timestamp format.
///
/// Simple format using: `%H` (hour), `%M` (minute), `%S` (second),
/// `%Y` (year), `%m` (month), `%d` (day).
#[must_use]
pub fn with_timestamp_format(mut self, format: impl Into<String>) -> Self {
self.timestamp_format = Some(format.into());
self
}
/// Set the file path and line number for caller info.
#[must_use]
pub fn with_path(mut self, file: impl Into<String>, line: u32) -> Self {
self.file_path = Some(file.into());
self.line_number = Some(line);
self
}
/// Set just the file path (without line number).
#[must_use]
pub fn with_file(mut self, file: impl Into<String>) -> Self {
self.file_path = Some(file.into());
self
}
/// Set just the line number.
#[must_use]
pub fn with_line(mut self, line: u32) -> Self {
self.line_number = Some(line);
self
}
/// Enable or disable level prefix display.
#[must_use]
pub fn with_level(mut self, show: bool) -> Self {
self.show_level = show;
self
}
/// Enable or disable keyword highlighting.
#[must_use]
pub fn with_highlight(mut self, highlight: bool) -> Self {
self.highlight = highlight;
self
}
}
/// RAII guard returned by [`Console::use_theme`].
pub struct ThemeGuard<'a> {
console: &'a Console,
}
impl Drop for ThemeGuard<'_> {
fn drop(&mut self) {
let _ = self.console.pop_theme();
}
}
/// Builder for creating a Console with custom settings.
#[derive(Default)]
pub struct ConsoleBuilder {
color_system: Option<ColorSystem>,
force_terminal: Option<bool>,
tab_size: Option<usize>,
markup: Option<bool>,
emoji: Option<bool>,
highlight: Option<bool>,
highlighter: Option<Arc<dyn Highlighter>>,
width: Option<usize>,
height: Option<usize>,
safe_box: Option<bool>,
theme: Option<Theme>,
file: Option<Box<dyn Write + Send>>,
}
impl std::fmt::Debug for ConsoleBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConsoleBuilder")
.field("color_system", &self.color_system)
.field("force_terminal", &self.force_terminal)
.field("tab_size", &self.tab_size)
.field("markup", &self.markup)
.field("emoji", &self.emoji)
.field("highlight", &self.highlight)
.field(
"highlighter",
&self.highlighter.as_ref().map(|_| "<Highlighter>"),
)
.field("width", &self.width)
.field("height", &self.height)
.field("safe_box", &self.safe_box)
.field("theme", &self.theme.as_ref().map(|_| "<Theme>"))
.field("file", &self.file.as_ref().map(|_| "<dyn Write>"))
.finish()
}
}
impl ConsoleBuilder {
/// Set the color system.
#[must_use]
pub fn color_system(mut self, system: ColorSystem) -> Self {
self.color_system = Some(system);
self
}
/// Disable colors.
#[must_use]
pub fn no_color(mut self) -> Self {
self.color_system = None;
self
}
/// Force terminal mode.
#[must_use]
pub fn force_terminal(mut self, force: bool) -> Self {
self.force_terminal = Some(force);
self
}
/// Set tab size.
#[must_use]
pub fn tab_size(mut self, size: usize) -> Self {
self.tab_size = Some(size);
self
}
/// Enable/disable markup parsing.
#[must_use]
pub fn markup(mut self, enabled: bool) -> Self {
self.markup = Some(enabled);
self
}
/// Enable/disable emoji.
#[must_use]
pub fn emoji(mut self, enabled: bool) -> Self {
self.emoji = Some(enabled);
self
}
/// Enable/disable highlighting.
#[must_use]
pub fn highlight(mut self, enabled: bool) -> Self {
self.highlight = Some(enabled);
self
}
/// Set the console's default highlighter.
#[must_use]
pub fn highlighter<H: Highlighter + 'static>(mut self, highlighter: H) -> Self {
self.highlighter = Some(Arc::new(highlighter));
self
}
/// Set console width.
#[must_use]
pub fn width(mut self, width: usize) -> Self {
self.width = Some(width);
self
}
/// Set console height.
#[must_use]
pub fn height(mut self, height: usize) -> Self {
self.height = Some(height);
self
}
/// Use ASCII-safe box characters.
#[must_use]
pub fn safe_box(mut self, safe: bool) -> Self {
self.safe_box = Some(safe);
self
}
/// Set the initial console theme.
#[must_use]
pub fn theme(mut self, theme: Theme) -> Self {
self.theme = Some(theme);
self
}
/// Set the output stream.
#[must_use]
pub fn file(mut self, writer: Box<dyn Write + Send>) -> Self {
self.file = Some(writer);
self
}
/// Build the console.
#[must_use]
pub fn build(self) -> Console {
let mut console = Console::new();
if let Some(cs) = self.color_system {
console.color_system = Some(cs);
}
if let Some(ft) = self.force_terminal {
console.force_terminal = Some(ft);
if console.color_system.is_none() {
console.detected_color_system = if ft {
terminal::detect_color_system_forced(true)
} else {
None
};
}
}
if let Some(ts) = self.tab_size {
console.tab_size = ts;
}
if let Some(m) = self.markup {
console.markup = m;
}
if let Some(e) = self.emoji {
console.emoji = e;
}
if let Some(h) = self.highlight {
console.highlight = h;
}
if let Some(highlighter) = self.highlighter {
console.highlighter = highlighter;
}
if let Some(w) = self.width {
console.width = Some(w);
}
if let Some(h) = self.height {
console.height = Some(h);
}
if let Some(sb) = self.safe_box {
console.safe_box = sb;
}
if let Some(theme) = self.theme {
console.theme_stack = Mutex::new(ThemeStack::new(theme));
}
if let Some(f) = self.file {
console.file = Mutex::new(f);
}
console
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::highlighter::NullHighlighter;
#[test]
fn test_console_new() {
let console = Console::new();
assert!(console.width() > 0);
assert!(console.height() > 0);
}
#[test]
fn test_console_builder() {
let console = Console::builder()
.width(100)
.height(50)
.markup(false)
.build();
assert_eq!(console.width(), 100);
assert_eq!(console.height(), 50);
assert!(!console.markup);
}
#[test]
fn test_console_default_highlighter_applies_when_enabled() {
let console = Console::builder().markup(false).build();
let opts = PrintOptions::new().with_markup(false).with_no_newline(true);
let segments = console.render_str_segments("True", &opts);
let expected = console.get_style("repr.bool_true");
assert!(segments.iter().any(|s| s.style.as_ref() == Some(&expected)));
}
#[test]
fn test_console_highlight_override_off_disables_highlighter() {
let console = Console::builder().markup(false).build();
let opts = PrintOptions::new()
.with_markup(false)
.with_no_newline(true)
.with_highlight(false);
let segments = console.render_str_segments("True", &opts);
let expected = console.get_style("repr.bool_true");
assert!(!segments.iter().any(|s| s.style.as_ref() == Some(&expected)));
}
#[test]
fn test_console_builder_highlighter_override() {
let console = Console::builder()
.markup(false)
.highlighter(NullHighlighter)
.build();
let opts = PrintOptions::new().with_markup(false).with_no_newline(true);
let segments = console.render_str_segments("True", &opts);
let expected = console.get_style("repr.bool_true");
assert!(!segments.iter().any(|s| s.style.as_ref() == Some(&expected)));
}
#[test]
fn test_console_print_options_highlighter_override() {
let console = Console::builder().markup(false).build();
let opts = PrintOptions::new()
.with_markup(false)
.with_no_newline(true)
.with_highlight(true)
.with_highlighter(NullHighlighter);
let segments = console.render_str_segments("True", &opts);
let expected = console.get_style("repr.bool_true");
assert!(!segments.iter().any(|s| s.style.as_ref() == Some(&expected)));
}
#[test]
fn test_console_options() {
let console = Console::builder().width(80).build();
let options = console.options();
assert_eq!(options.max_width, 80);
assert_eq!(options.size.width, 80);
}
#[test]
fn test_print_options() {
let options = PrintOptions::new()
.with_markup(true)
.with_style(Style::new().bold());
assert_eq!(options.markup, Some(true));
assert!(options.style.is_some());
}
#[test]
fn test_capture() {
let console = Console::new();
console.begin_capture();
console.print_plain("capture test");
let segments = console.end_capture();
let captured: String = segments.iter().map(|s| s.text.as_ref()).collect();
assert!(captured.contains("capture test"));
}
#[test]
fn test_capture_collects_segments() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(40)
.markup(false)
.file(Box::new(buffer))
.build();
console.begin_capture();
console.print_plain("Hello");
let segments = console.end_capture();
let captured: String = segments.iter().map(|s| s.text.as_ref()).collect();
assert!(captured.contains("Hello"));
}
#[test]
fn test_print_exception_renders_traceback() {
use crate::renderables::{Traceback, TracebackFrame};
let console = Console::builder().width(60).markup(false).build();
console.begin_capture();
let traceback = Traceback::new(
vec![
TracebackFrame::new("<module>", 14),
TracebackFrame::new("level1", 11),
],
"ErrorType",
"boom",
);
console.print_exception(&traceback);
let segments = console.end_capture();
let captured: String = segments.iter().map(|s| s.text.as_ref()).collect();
assert!(captured.contains("Traceback (most recent call last)"));
assert!(captured.contains("in <module>:14"));
assert!(captured.contains("ErrorType: boom"));
}
#[test]
fn test_dimensions() {
let dims = ConsoleDimensions::default();
assert_eq!(dims.width, 80);
assert_eq!(dims.height, 24);
}
#[test]
fn test_custom_output_stream() {
use std::sync::{Arc, Mutex};
// Thread-safe buffer that implements Write + Send
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.markup(false)
.file(Box::new(buffer.clone()))
.build();
console.print_plain("Hello, World!");
let output = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&output);
assert!(
text.contains("Hello, World!"),
"Expected 'Hello, World!' in output, got: {text}"
);
}
#[test]
fn test_print_plain_disables_markup() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.markup(true)
.file(Box::new(buffer.clone()))
.build();
console.print_plain("[bold]Hello[/]");
let output = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&output);
assert!(
text.contains("[bold]Hello[/]"),
"Expected literal markup in output, got: {text}"
);
assert!(
!text.contains("\x1b["),
"Did not expect ANSI sequences in output, got: {text}"
);
}
#[test]
fn test_export_text_defaults() {
let console = Console::builder().markup(true).build();
let output = console.export_text("[bold]Hello[/]");
assert_eq!(output, "Hello\n");
}
#[test]
fn test_export_text_respects_markup_setting() {
let console = Console::builder().markup(false).build();
let output = console.export_text("[bold]Hello[/]");
assert_eq!(output, "[bold]Hello[/]\n");
}
#[test]
fn test_export_text_replaces_emoji_codes_by_default() {
let console = Console::builder().markup(false).build();
let output = console.export_text("hi :smile:");
assert_eq!(output, "hi π\n");
}
#[test]
fn test_export_text_does_not_replace_emoji_codes_when_disabled() {
let console = Console::builder().markup(false).emoji(false).build();
let output = console.export_text("hi :smile:");
assert_eq!(output, "hi :smile:\n");
}
#[test]
fn test_export_text_with_options_no_newline() {
let console = Console::new();
let mut options = PrintOptions::new().with_markup(false);
options.no_newline = true;
let output = console.export_text_with_options("Hello", &options);
assert_eq!(output, "Hello");
}
#[test]
fn test_export_renderable_text() {
use crate::renderables::Rule;
let console = Console::builder().width(20).build();
let rule = Rule::with_title("Title");
let output = console.export_renderable_text(&rule);
assert!(output.contains("Title"));
assert!(output.ends_with('\n'));
}
#[test]
fn test_export_html_svg_capture() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.markup(false)
.file(Box::new(buffer))
.build();
console.begin_capture();
console.print_plain("Hello");
let html = console.export_html(false);
assert!(html.contains("<pre"));
assert!(html.contains("Hello"));
let svg = console.export_svg(true);
assert!(svg.contains("<svg"));
assert!(svg.contains("Hello"));
let cleared = console.export_html(false);
assert!(!cleared.contains("Hello"));
}
#[test]
fn test_escape_html_entities() {
let escaped = escape_html_rich("<>&\"'");
assert_eq!(escaped, "<>&"'");
}
#[test]
fn test_style_html_rule_basic_attributes() {
use crate::color::Color;
let style = Style::new()
.color(Color::from_rgb(255, 0, 0))
.bgcolor(Color::from_rgb(0, 0, 255))
.bold()
.italic()
.underline()
.strike();
let css = style.get_html_style(DEFAULT_TERMINAL_THEME);
assert!(css.contains("color: #ff0000"));
assert!(css.contains("background-color: #0000ff"));
assert!(css.contains("font-weight: bold"));
assert!(css.contains("font-style: italic"));
assert!(css.contains("text-decoration: underline"));
assert!(css.contains("text-decoration: line-through"));
}
#[test]
fn test_style_html_rule_reverse_swaps_colors() {
use crate::color::Color;
let style = Style::new()
.color(Color::from_rgb(10, 20, 30))
.bgcolor(Color::from_rgb(200, 210, 220))
.reverse();
let css = style.get_html_style(DEFAULT_TERMINAL_THEME);
assert!(css.contains("color: #c8d2dc"));
assert!(css.contains("background-color: #0a141e"));
}
#[test]
fn test_export_html_body_links_and_spans() {
let link_style = Style::new().link("https://example.com").bold();
let segments = vec![
Segment::new("Link", Some(link_style)),
Segment::new(" ", None),
Segment::new("Plain", None),
];
let opts = ExportHtmlOptions {
inline_styles: true,
code_format: Some("{code}".to_string()),
..ExportHtmlOptions::default()
};
let html = export_segments_to_html_rich(&segments, &opts);
assert!(html.contains("href=\"https://example.com\""));
assert!(html.contains("font-weight: bold"));
assert!(html.contains("Plain"));
}
#[test]
fn test_export_html_escapes_text() {
let segments = vec![Segment::plain("<tag> & \"quote\"")];
let opts = ExportHtmlOptions {
inline_styles: true,
code_format: Some("{code}".to_string()),
..ExportHtmlOptions::default()
};
let html = export_segments_to_html_rich(&segments, &opts);
assert!(html.contains("<tag>"));
assert!(html.contains("&"));
assert!(html.contains("""));
}
#[test]
fn test_export_html_skips_control_segments() {
use crate::segment::{ControlCode, ControlType};
let segments = vec![
Segment::control(vec![ControlCode::new(ControlType::Bell)]),
Segment::new("Hi", None),
];
let opts = ExportHtmlOptions {
inline_styles: true,
code_format: Some("{code}".to_string()),
..ExportHtmlOptions::default()
};
let html = export_segments_to_html_rich(&segments, &opts);
assert!(html.contains("Hi"));
assert!(!html.contains("Bell"));
}
#[test]
fn test_export_svg_dimensions() {
let segments = vec![Segment::plain("AB"), Segment::line(), Segment::plain("C")];
let opts = ExportSvgOptions {
code_format: Some("{width}x{height}".to_string()),
..ExportSvgOptions::default()
};
let svg = export_segments_to_svg_rich(&segments, 2, &opts);
assert!(svg.contains('x'));
}
#[test]
fn test_export_svg_includes_text() {
let segments = vec![Segment::plain("Hello")];
let opts = ExportSvgOptions {
code_format: Some("{matrix}".to_string()),
..ExportSvgOptions::default()
};
let svg = export_segments_to_svg_rich(&segments, 10, &opts);
assert!(svg.contains("Hello"));
}
#[test]
fn test_export_html_document_structure() {
let segments = vec![Segment::plain("Hello")];
let opts = ExportHtmlOptions::default();
let html = export_segments_to_html_rich(&segments, &opts);
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.contains("<meta charset=\"UTF-8\">"));
assert!(html.contains("<body>"));
assert!(html.contains("</html>"));
}
#[test]
fn test_export_html_includes_renderable_content() {
use crate::renderables::{Column, Panel, Table, Tree, TreeNode};
let console = Console::builder().width(30).build();
console.begin_capture();
let mut table = Table::new().with_column(Column::new("Col"));
table.add_row_cells(["Cell"]);
console.print_renderable(&table);
let panel = Panel::from_text("Panel").width(10);
console.print_renderable(&panel);
let root = TreeNode::new("Root").child(TreeNode::new("Leaf"));
let tree = Tree::new(root);
console.print_renderable(&tree);
let html = console.export_html(true);
assert!(html.contains("Col"));
assert!(html.contains("Cell"));
assert!(html.contains("Panel"));
assert!(html.contains("Root"));
assert!(html.contains("Leaf"));
}
#[test]
fn test_print_options_justify_uses_console_width() {
let console = Console::builder().width(10).markup(false).build();
let mut output = Vec::new();
let mut options = PrintOptions::new().with_justify(JustifyMethod::Center);
options.no_newline = true;
console
.print_to(&mut output, "Hi", &options)
.expect("failed to render");
let text = String::from_utf8(output).expect("invalid utf8");
assert_eq!(text, " Hi ");
}
#[test]
fn test_print_options_width_wraps() {
let console = Console::builder().width(80).markup(false).build();
let mut output = Vec::new();
let mut options = PrintOptions::new();
options.width = Some(4);
console
.print_to(&mut output, "Hello", &options)
.expect("failed to render");
let text = String::from_utf8(output).expect("invalid utf8");
assert_eq!(text, "Hell\no\n");
}
#[test]
fn test_print_options_no_wrap_ellipsis() {
let console = Console::builder().width(80).markup(false).build();
let mut output = Vec::new();
let mut options = PrintOptions::new()
.with_no_wrap(true)
.with_overflow(OverflowMethod::Ellipsis);
options.width = Some(4);
options.no_newline = true;
console
.print_to(&mut output, "Hello", &options)
.expect("failed to render");
let text = String::from_utf8(output).expect("invalid utf8");
assert_eq!(text, "H...");
}
#[test]
fn test_custom_output_stream_line() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.file(Box::new(buffer.clone()))
.build();
console.line();
let output = buffer.0.lock().unwrap();
let text = String::from_utf8_lossy(&output);
assert_eq!(text, "\n", "Expected single newline, got: {text:?}");
}
// ========== ConsoleBuilder Tests ==========
#[test]
fn test_console_builder_color_system() {
let console = Console::builder()
.color_system(ColorSystem::TrueColor)
.build();
assert_eq!(console.color_system(), Some(ColorSystem::TrueColor));
}
#[test]
fn test_console_builder_no_color() {
let console = Console::builder().no_color().build();
assert_eq!(console.color_system, None);
}
#[test]
fn test_console_builder_force_terminal() {
let console = Console::builder().force_terminal(true).build();
assert!(console.is_terminal());
}
#[test]
fn test_console_builder_tab_size() {
let console = Console::builder().tab_size(4).build();
assert_eq!(console.tab_size(), 4);
}
#[test]
fn test_console_builder_emoji() {
let console = Console::builder().emoji(false).build();
assert!(!console.emoji);
}
#[test]
fn test_console_builder_highlight() {
let console = Console::builder().highlight(false).build();
assert!(!console.highlight);
}
#[test]
fn test_console_builder_safe_box() {
let console = Console::builder().safe_box(true).build();
assert!(console.safe_box);
}
#[test]
fn test_console_builder_all_options() {
let console = Console::builder()
.color_system(ColorSystem::EightBit)
.force_terminal(true)
.tab_size(2)
.markup(false)
.emoji(false)
.highlight(false)
.width(120)
.height(40)
.safe_box(true)
.build();
assert_eq!(console.color_system(), Some(ColorSystem::EightBit));
assert!(console.is_terminal());
assert_eq!(console.tab_size(), 2);
assert!(!console.markup);
assert!(!console.emoji);
assert!(!console.highlight);
assert_eq!(console.width(), 120);
assert_eq!(console.height(), 40);
assert!(console.safe_box);
}
// ========== Console Size Tests ==========
#[test]
fn test_console_size_returns_dimensions() {
let console = Console::builder().width(100).height(50).build();
let size = console.size();
assert_eq!(size.width, 100);
assert_eq!(size.height, 50);
}
#[test]
fn test_console_default_dimensions() {
let console = Console::new();
// Default should be reasonable terminal size
assert!(console.width() >= 40);
assert!(console.height() >= 10);
}
// ========== PrintOptions Tests ==========
#[test]
fn test_print_options_default() {
let options = PrintOptions::new();
assert_eq!(options.markup, None);
assert!(options.style.is_none());
assert_eq!(options.sep, " ");
assert_eq!(options.end, "\n");
assert_eq!(options.no_wrap, None);
assert!(!options.no_newline);
assert_eq!(options.highlight, None);
}
#[test]
fn test_print_options_with_sep() {
let options = PrintOptions::new().with_sep(", ");
assert_eq!(options.sep, ", ");
}
#[test]
fn test_print_options_with_end() {
let options = PrintOptions::new().with_end("\r\n");
assert_eq!(options.end, "\r\n");
}
#[test]
fn test_print_options_with_overflow() {
let options = PrintOptions::new().with_overflow(OverflowMethod::Crop);
assert_eq!(options.overflow, Some(OverflowMethod::Crop));
}
#[test]
fn test_print_options_with_crop() {
let options = PrintOptions::new().with_crop(true);
assert!(options.crop);
}
#[test]
fn test_print_options_with_soft_wrap() {
let options = PrintOptions::new().with_soft_wrap(true);
assert!(options.soft_wrap);
}
#[test]
fn test_print_options_chained() {
let style = Style::new().bold().italic();
let options = PrintOptions::new()
.with_markup(false)
.with_style(style.clone())
.with_sep(" | ")
.with_end("")
.with_justify(JustifyMethod::Right)
.with_overflow(OverflowMethod::Ellipsis)
.with_no_wrap(true)
.with_no_newline(true)
.with_highlight(true)
.with_width(40)
.with_crop(true)
.with_soft_wrap(true);
assert_eq!(options.markup, Some(false));
assert!(options.style.is_some());
assert_eq!(options.sep, " | ");
assert_eq!(options.end, "");
assert_eq!(options.justify, Some(JustifyMethod::Right));
assert_eq!(options.overflow, Some(OverflowMethod::Ellipsis));
assert_eq!(options.no_wrap, Some(true));
assert!(options.no_newline);
assert_eq!(options.highlight, Some(true));
assert_eq!(options.width, Some(40));
assert!(options.crop);
assert!(options.soft_wrap);
}
// ========== ConsoleOptions Tests ==========
#[test]
fn test_console_options_update_width() {
let console = Console::builder().width(100).build();
let options = console.options();
// update_width clamps to the new width (min of current and new)
let updated = options.update_width(80);
assert_eq!(updated.max_width, 80);
}
#[test]
fn test_console_options_update_height() {
let console = Console::builder().height(24).build();
let options = console.options();
// update_height sets the height in the options
let updated = options.update_height(50);
assert_eq!(updated.height, Some(50));
}
// ========== Color System Tests ==========
#[test]
fn test_console_is_color_enabled_with_system() {
let console = Console::builder()
.color_system(ColorSystem::Standard)
.build();
assert!(console.is_color_enabled());
}
#[test]
fn test_console_is_color_enabled_no_color() {
let console = Console::builder().no_color().build();
assert!(!console.is_color_enabled());
}
// ========== Capture Mode Tests ==========
#[test]
fn test_capture_empty() {
let console = Console::new();
console.begin_capture();
let segments = console.end_capture();
assert!(segments.is_empty());
}
#[test]
fn test_capture_with_styled_text() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.markup(true)
.color_system(ColorSystem::TrueColor)
.file(Box::new(buffer))
.build();
console.begin_capture();
console.print("[bold]Test[/]");
let segments = console.end_capture();
// Should have captured at least one segment
assert!(!segments.is_empty());
}
#[test]
fn test_capture_multiple_prints() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.markup(false)
.file(Box::new(buffer))
.build();
console.begin_capture();
console.print_plain("First");
console.print_plain("Second");
let segments = console.end_capture();
let text: String = segments.iter().map(|s| s.text.as_ref()).collect();
assert!(text.contains("First"));
assert!(text.contains("Second"));
}
// ========== Print Method Tests ==========
#[test]
fn test_print_text_direct() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.markup(false)
.file(Box::new(buffer.clone()))
.build();
let text = Text::new("Direct text");
console.print_text(&text);
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
assert!(result.contains("Direct text"));
}
#[test]
fn test_print_styled() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.color_system(ColorSystem::TrueColor)
.file(Box::new(buffer.clone()))
.build();
console.print_styled("Styled", Style::new().bold());
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
assert!(result.contains("Styled"));
// Should contain ANSI codes for bold
assert!(result.contains("\x1b["));
}
#[test]
fn test_print_to_writer() {
let console = Console::builder().width(80).markup(false).build();
let mut output = Vec::new();
let options = PrintOptions::new();
console
.print_to(&mut output, "Writer test", &options)
.expect("failed to print");
let text = String::from_utf8(output).expect("invalid utf8");
assert!(text.contains("Writer test"));
}
#[test]
fn test_print_segments() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.file(Box::new(buffer.clone()))
.build();
let segments = vec![Segment::plain("Hello "), Segment::plain("World")];
console.print_segments(&segments);
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
assert!(result.contains("Hello "));
assert!(result.contains("World"));
}
// ========== Rule Method Tests ==========
#[test]
fn test_rule_without_title() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(20)
.file(Box::new(buffer.clone()))
.build();
console.rule(None);
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
// Rule should contain horizontal line characters
assert!(result.contains('β') || result.contains('-'));
}
#[test]
fn test_rule_with_title() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(40)
.file(Box::new(buffer.clone()))
.build();
console.rule(Some("Section"));
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
assert!(result.contains("Section"));
}
// ========== Log Method Tests ==========
#[test]
fn test_log_debug() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.file(Box::new(buffer.clone()))
.build();
console.log("Debug message", LogLevel::Debug);
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
assert!(result.contains("Debug message"));
}
#[test]
fn test_log_info() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.file(Box::new(buffer.clone()))
.build();
console.log("Info message", LogLevel::Info);
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
assert!(result.contains("Info message"));
}
#[test]
fn test_log_warning() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.file(Box::new(buffer.clone()))
.build();
console.log("Warning message", LogLevel::Warning);
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
assert!(result.contains("Warning message"));
}
#[test]
fn test_log_error() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.file(Box::new(buffer.clone()))
.build();
console.log("Error message", LogLevel::Error);
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
assert!(result.contains("Error message"));
}
// ========== Log with Options Tests ==========
#[test]
fn test_log_with_timestamp() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.file(Box::new(buffer.clone()))
.build();
let opts = LogOptions::new().with_timestamp(true);
console.log_with_options("Test message", LogLevel::Info, &opts);
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
// Should contain timestamp format [HH:MM:SS]
assert!(result.contains('['));
assert!(result.contains(']'));
assert!(result.contains(':'));
assert!(result.contains("Test message"));
}
#[test]
fn test_log_with_file_path() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.file(Box::new(buffer.clone()))
.build();
let opts = LogOptions::new().with_path("src/main.rs", 42);
console.log_with_options("Debug info", LogLevel::Debug, &opts);
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
assert!(result.contains("src/main.rs"));
assert!(result.contains("42"));
assert!(result.contains("Debug info"));
}
#[test]
fn test_log_with_timestamp_and_path() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.file(Box::new(buffer.clone()))
.build();
let opts = LogOptions::new()
.with_timestamp(true)
.with_path("test.rs", 100);
console.log_with_options("Combined test", LogLevel::Warning, &opts);
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
assert!(result.contains('[')); // timestamp bracket
assert!(result.contains("test.rs"));
assert!(result.contains("100"));
assert!(result.contains("Combined test"));
}
#[test]
fn test_log_without_level() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.file(Box::new(buffer.clone()))
.build();
let opts = LogOptions::new().with_level(false);
console.log_with_options("No level prefix", LogLevel::Info, &opts);
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
assert!(!result.contains("[INFO]"));
assert!(result.contains("No level prefix"));
}
#[test]
fn test_log_options_default() {
let opts = LogOptions::default();
assert!(!opts.show_timestamp);
assert!(opts.timestamp_format.is_none());
assert!(opts.file_path.is_none());
assert!(opts.line_number.is_none());
assert!(opts.show_level);
assert!(!opts.highlight);
}
#[test]
fn test_log_options_builder() {
let opts = LogOptions::new()
.with_timestamp(true)
.with_timestamp_format("%Y-%m-%d %H:%M:%S")
.with_file("test.rs")
.with_line(123)
.with_level(false)
.with_highlight(true);
assert!(opts.show_timestamp);
assert_eq!(opts.timestamp_format, Some("%Y-%m-%d %H:%M:%S".to_string()));
assert_eq!(opts.file_path, Some("test.rs".to_string()));
assert_eq!(opts.line_number, Some(123));
assert!(!opts.show_level);
assert!(opts.highlight);
}
#[test]
fn test_format_timestamp_default() {
let ts = Console::format_timestamp(None);
// Default format: [HH:MM:SS]
assert!(ts.starts_with('['));
assert!(ts.ends_with(']'));
assert_eq!(ts.matches(':').count(), 2);
}
#[test]
fn test_format_timestamp_custom() {
let ts = Console::format_timestamp(Some("%H-%M-%S"));
// Custom format: HH-MM-SS
assert_eq!(ts.matches('-').count(), 2);
assert!(!ts.contains(':'));
}
#[test]
fn test_format_timestamp_custom_with_date_tokens() {
let ts = Console::format_timestamp(Some("%Y-%m-%d %H:%M:%S"));
// We don't assert wall-clock values; we only assert the substitutions happened.
assert_eq!(ts.len(), "0000-00-00 00:00:00".len());
assert_eq!(ts.matches('-').count(), 2);
assert_eq!(ts.matches(':').count(), 2);
}
// ========== Markup Integration Tests ==========
#[test]
fn test_markup_enabled() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.markup(true)
.color_system(ColorSystem::TrueColor)
.file(Box::new(buffer.clone()))
.build();
console.print("[bold]Bold text[/]");
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
// Should contain ANSI codes, not literal [bold]
assert!(!result.contains("[bold]"));
assert!(result.contains("\x1b["));
}
#[test]
fn test_markup_disabled() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.markup(false)
.file(Box::new(buffer.clone()))
.build();
console.print("[bold]Literal markup[/]");
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
// Should contain literal markup tags
assert!(result.contains("[bold]"));
}
// ========== Width Constraint Tests ==========
#[test]
fn test_print_with_width_constraint() {
let console = Console::builder().width(80).markup(false).build();
let mut output = Vec::new();
let mut options = PrintOptions::new();
options.width = Some(10);
console
.print_to(
&mut output,
"This is a long text that should wrap",
&options,
)
.expect("failed to print");
let text = String::from_utf8(output).expect("invalid utf8");
// Text should be wrapped at width 10
let lines: Vec<&str> = text.lines().collect();
assert!(lines.len() > 1);
}
#[test]
fn test_justify_left() {
let console = Console::builder().width(20).markup(false).build();
let mut output = Vec::new();
let mut options = PrintOptions::new().with_justify(JustifyMethod::Left);
options.no_newline = true;
console
.print_to(&mut output, "Left", &options)
.expect("failed to print");
let text = String::from_utf8(output).expect("invalid utf8");
assert!(text.starts_with("Left"));
}
#[test]
fn test_justify_right() {
let console = Console::builder().width(20).markup(false).build();
let mut output = Vec::new();
let mut options = PrintOptions::new().with_justify(JustifyMethod::Right);
options.no_newline = true;
console
.print_to(&mut output, "Right", &options)
.expect("failed to print");
let text = String::from_utf8(output).expect("invalid utf8");
assert!(text.ends_with("Right"));
assert!(text.len() == 20);
}
// ========== ConsoleDimensions Tests ==========
#[test]
fn test_console_dimensions_default() {
let dims = ConsoleDimensions::default();
assert_eq!(dims.width, 80);
assert_eq!(dims.height, 24);
}
#[test]
fn test_console_dimensions_custom() {
let dims = ConsoleDimensions {
width: 120,
height: 40,
};
assert_eq!(dims.width, 120);
assert_eq!(dims.height, 40);
}
// ========== PrintOptions Default Trait ==========
#[test]
fn test_print_options_implements_default() {
// Default::default() uses derived defaults (empty strings)
// PrintOptions::new() sets explicit defaults (sep=" ", end="\n")
let default_options = PrintOptions::default();
assert_eq!(default_options.sep, "");
assert_eq!(default_options.end, "");
// new() provides the typical defaults
let new_options = PrintOptions::new();
assert_eq!(new_options.sep, " ");
assert_eq!(new_options.end, "\n");
}
// ========== Edge Case Tests ==========
#[test]
fn test_print_empty_string() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.file(Box::new(buffer.clone()))
.build();
console.print_plain("");
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
// Should only have newline
assert_eq!(result.trim(), "");
}
#[test]
fn test_print_unicode() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.file(Box::new(buffer.clone()))
.build();
console.print_plain("Hello δΈη π");
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
assert!(result.contains("δΈη"));
assert!(result.contains("π"));
}
#[test]
fn test_print_with_newlines() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buffer = SharedBuffer(Arc::new(Mutex::new(Vec::new())));
let console = Console::builder()
.width(80)
.file(Box::new(buffer.clone()))
.build();
console.print_plain("Line 1\nLine 2\nLine 3");
let output = buffer.0.lock().unwrap();
let result = String::from_utf8_lossy(&output);
let lines: Vec<&str> = result.lines().collect();
assert!(lines.len() >= 3);
}
#[test]
fn test_overflow_crop() {
let console = Console::builder().width(80).markup(false).build();
let mut output = Vec::new();
let mut options = PrintOptions::new()
.with_no_wrap(true)
.with_overflow(OverflowMethod::Crop);
options.width = Some(5);
options.no_newline = true;
console
.print_to(&mut output, "Hello World", &options)
.expect("failed to print");
let text = String::from_utf8(output).expect("invalid utf8");
assert_eq!(text, "Hello");
}
// ========================================================================
// Console I/O Error Path Tests (bd-3761)
// ========================================================================
/// A writer that always fails on write
struct FailingWriter;
impl Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::new(io::ErrorKind::BrokenPipe, "write failed"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
/// A writer that fails on flush
struct FlushFailingWriter {
buffer: Vec<u8>,
}
impl FlushFailingWriter {
fn new() -> Self {
Self { buffer: Vec::new() }
}
}
impl Write for FlushFailingWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.buffer.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::other("flush failed: disk full"))
}
}
/// A writer that fails after N bytes
struct LimitedWriter {
limit: usize,
written: usize,
}
impl LimitedWriter {
fn new(limit: usize) -> Self {
Self { limit, written: 0 }
}
}
impl Write for LimitedWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.written >= self.limit {
return Err(io::Error::new(io::ErrorKind::WriteZero, "buffer full"));
}
let available = self.limit - self.written;
let to_write = buf.len().min(available);
self.written += to_write;
Ok(to_write)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
/// A writer that tracks operations for verification
struct TrackingWriter {
writes: Arc<Mutex<Vec<usize>>>,
flushes: Arc<Mutex<usize>>,
}
impl TrackingWriter {
fn new() -> Self {
Self {
writes: Arc::new(Mutex::new(Vec::new())),
flushes: Arc::new(Mutex::new(0)),
}
}
fn write_count(&self) -> usize {
self.writes.lock().unwrap().len()
}
#[allow(dead_code)]
fn flush_count(&self) -> usize {
*self.flushes.lock().unwrap()
}
fn total_bytes(&self) -> usize {
self.writes.lock().unwrap().iter().sum()
}
}
impl Clone for TrackingWriter {
fn clone(&self) -> Self {
Self {
writes: Arc::clone(&self.writes),
flushes: Arc::clone(&self.flushes),
}
}
}
impl Write for TrackingWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.writes.lock().unwrap().push(buf.len());
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
*self.flushes.lock().unwrap() += 1;
Ok(())
}
}
#[test]
fn test_io_write_failure() {
// Test that write errors are properly propagated via print_to
let console = Console::builder().width(80).markup(false).build();
let mut failing_writer = FailingWriter;
let result = console.print_to(&mut failing_writer, "Hello", &PrintOptions::new());
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
}
#[test]
fn test_io_write_partial() {
// Test writer that accepts only partial writes
let console = Console::builder().width(80).markup(false).build();
let mut limited = LimitedWriter::new(5);
let _result = console.print_to(&mut limited, "Hello World!", &PrintOptions::new());
// May succeed partially or fail depending on implementation
// The writer should have accepted at least some bytes
assert!(limited.written > 0);
}
#[test]
fn test_io_flush_failure() {
// Test that flush errors are handled
let mut writer = FlushFailingWriter::new();
// Write should succeed
let write_result = writer.write(b"Hello");
assert!(write_result.is_ok());
assert_eq!(write_result.unwrap(), 5);
// Flush should fail
let flush_result = writer.flush();
assert!(flush_result.is_err());
let err = flush_result.unwrap_err();
assert!(err.to_string().contains("flush failed"));
}
#[test]
fn test_io_write_segments_to_failing() {
let console = Console::builder().width(80).markup(false).build();
// Create segments
let segments = vec![
Segment::plain("Hello "),
Segment::styled("World", Style::new().bold()),
];
let mut failing_writer = FailingWriter;
let result = console.print_segments_to(&mut failing_writer, &segments);
assert!(result.is_err());
}
#[test]
fn test_io_print_text_to_failing() {
let console = Console::builder().width(80).markup(false).build();
let text = Text::new("Hello World");
let mut failing_writer = FailingWriter;
let result = console.print_text_to(&mut failing_writer, &text);
assert!(result.is_err());
}
#[test]
fn test_io_write_tracking() {
// Verify writes are actually occurring
let tracking = TrackingWriter::new();
let console = Console::builder()
.width(80)
.markup(false)
.file(Box::new(tracking.clone()))
.build();
console.print_plain("Line 1");
console.print_plain("Line 2");
// Should have multiple writes
assert!(tracking.write_count() >= 2, "Expected writes to occur");
assert!(tracking.total_bytes() > 0, "Expected bytes written");
}
#[test]
fn test_io_empty_write() {
// Writing empty content should not cause errors
let console = Console::builder().width(80).markup(false).build();
let mut output = Vec::new();
let result = console.print_to(&mut output, "", &PrintOptions::new().with_no_newline(true));
assert!(result.is_ok());
// Empty string with no_newline should produce empty output
assert!(output.is_empty() || output == b"\n");
}
#[test]
fn test_io_large_write() {
// Test with a large string to ensure no buffer issues
let console = Console::builder().width(1000).markup(false).build();
let large_content = "x".repeat(10000);
let mut output = Vec::new();
let result = console.print_to(&mut output, &large_content, &PrintOptions::new());
assert!(result.is_ok());
// Should contain all the content plus newline
assert!(output.len() >= 10000);
}
#[test]
fn test_io_control_code_write_failure() {
// Test that control code writes handle errors
// Note: This tests internal behavior, so we use print_segments_to
let console = Console::builder().width(80).markup(false).build();
// Create a segment with control codes
let segments = vec![Segment {
text: std::borrow::Cow::Borrowed(""),
style: None,
control: Some(vec![ControlCode::new(ControlType::Home)]),
}];
let mut failing_writer = FailingWriter;
let result = console.print_segments_to(&mut failing_writer, &segments);
// Should handle the error (either succeed because control codes are skipped
// in non-terminal mode, or fail gracefully)
// The important thing is no panic
let _ = result;
}
#[test]
fn test_control_cursor_move_to_column_is_zero_based() {
let console = Console::builder()
.width(80)
.markup(false)
.force_terminal(true)
.build();
let segments = vec![Segment::control(vec![ControlCode::with_params_vec(
ControlType::CursorMoveToColumn,
vec![0],
)])];
let mut output = Vec::new();
console
.print_segments_to(&mut output, &segments)
.expect("print_segments_to");
assert_eq!(String::from_utf8(output).expect("utf8 output"), "\x1b[1G");
}
#[test]
fn test_control_cursor_move_to_is_zero_based_xy() {
let console = Console::builder()
.width(80)
.markup(false)
.force_terminal(true)
.build();
let segments = vec![Segment::control(vec![ControlCode::with_params_vec(
ControlType::CursorMoveTo,
vec![3, 4],
)])];
let mut output = Vec::new();
console
.print_segments_to(&mut output, &segments)
.expect("print_segments_to");
assert_eq!(String::from_utf8(output).expect("utf8 output"), "\x1b[5;4H");
}
#[test]
fn test_control_set_window_title_emits_empty_title_sequence() {
let console = Console::builder()
.width(80)
.markup(false)
.force_terminal(true)
.build();
let segments = vec![Segment {
text: std::borrow::Cow::Borrowed(""),
style: None,
control: Some(vec![ControlCode::new(ControlType::SetWindowTitle)]),
}];
let mut output = Vec::new();
console
.print_segments_to(&mut output, &segments)
.expect("print_segments_to");
assert_eq!(
String::from_utf8(output).expect("utf8 output"),
"\x1b]0;\x07"
);
}
#[test]
fn test_io_error_types() {
// Create writers with different error types
struct NotFoundWriter;
impl Write for NotFoundWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::new(io::ErrorKind::NotFound, "file not found"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
struct PermissionWriter;
impl Write for PermissionWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"access denied",
))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
// Verify different error types are preserved
let console = Console::builder().width(80).markup(false).build();
let mut not_found = NotFoundWriter;
let result1 = console.print_to(&mut not_found, "test", &PrintOptions::new());
assert!(matches!(
result1.as_ref().map_err(std::io::Error::kind),
Err(io::ErrorKind::NotFound)
));
let mut permission = PermissionWriter;
let result2 = console.print_to(&mut permission, "test", &PrintOptions::new());
assert!(matches!(
result2.as_ref().map_err(std::io::Error::kind),
Err(io::ErrorKind::PermissionDenied)
));
}
#[test]
fn test_io_concurrent_writes() {
use std::thread;
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Clone for SharedBuffer {
fn clone(&self) -> Self {
Self(Arc::clone(&self.0))
}
}
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
// Test thread-safe writes to shared buffer
let buffer = Arc::new(Mutex::new(Vec::new()));
let shared = SharedBuffer(Arc::clone(&buffer));
let console = Console::builder()
.width(80)
.markup(false)
.file(Box::new(shared))
.build()
.shared();
// Spawn multiple threads writing concurrently
let mut handles = vec![];
for i in 0..4 {
let console_clone = Arc::clone(&console);
let handle = thread::spawn(move || {
console_clone.print_plain(&format!("Thread {i}"));
});
handles.push(handle);
}
for handle in handles {
handle.join().expect("thread panicked");
}
// Verify all writes completed
let output = buffer.lock().unwrap();
let text = String::from_utf8_lossy(&output);
// All 4 threads should have written something
assert!(text.contains("Thread"), "Expected thread output");
}
#[test]
fn test_io_interrupted_write() {
// Test handling of interrupted writes (EINTR-like scenario)
struct InterruptedWriter {
attempts: Arc<Mutex<usize>>,
succeed_after: usize,
}
impl Write for InterruptedWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut attempts = self.attempts.lock().unwrap();
*attempts += 1;
if *attempts <= self.succeed_after {
Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted"))
} else {
Ok(buf.len())
}
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let console = Console::builder().width(80).markup(false).build();
// Writer that returns Interrupted initially
let attempts = Arc::new(Mutex::new(0));
let mut writer = InterruptedWriter {
attempts: Arc::clone(&attempts),
succeed_after: 0, // Succeed on first try
};
let result = console.print_to(&mut writer, "test", &PrintOptions::new());
assert!(
result.is_ok()
|| result.as_ref().map_err(std::io::Error::kind) == Err(io::ErrorKind::Interrupted)
);
}
}