use crate::mode::OutputMode;
use crate::testing::{OutputEntry, OutputLevel, TestOutput};
use crate::themes::FastApiTheme;
use parking_lot::RwLock;
use std::cell::RefCell;
use std::sync::LazyLock;
use std::time::Instant;
const ANSI_RESET: &str = "\x1b[0m";
static GLOBAL_OUTPUT: LazyLock<RwLock<RichOutput>> =
LazyLock::new(|| RwLock::new(RichOutput::auto()));
thread_local! {
static TEST_OUTPUT: RefCell<Option<TestOutput>> = const { RefCell::new(None) };
}
pub fn get_global() -> parking_lot::RwLockReadGuard<'static, RichOutput> {
GLOBAL_OUTPUT.read()
}
pub fn set_global(output: RichOutput) {
*GLOBAL_OUTPUT.write() = output;
}
#[derive(Debug, Clone)]
pub struct RichOutput {
mode: OutputMode,
theme: FastApiTheme,
}
impl RichOutput {
#[must_use]
pub fn new(mode: OutputMode) -> Self {
Self {
mode,
theme: FastApiTheme::default(),
}
}
#[must_use]
pub fn with_mode(mode: OutputMode) -> Self {
Self::new(mode)
}
#[must_use]
pub fn auto() -> Self {
Self::new(OutputMode::auto())
}
#[must_use]
pub fn rich() -> Self {
Self::new(OutputMode::Rich)
}
#[must_use]
pub fn plain() -> Self {
Self::new(OutputMode::Plain)
}
#[must_use]
pub fn builder() -> RichOutputBuilder {
RichOutputBuilder::new()
}
#[must_use]
pub const fn mode(&self) -> OutputMode {
self.mode
}
pub fn set_mode(&mut self, mode: OutputMode) {
self.mode = mode;
}
#[must_use]
pub const fn is_agent_mode(&self) -> bool {
matches!(self.mode, OutputMode::Plain)
}
#[must_use]
pub const fn mode_name(&self) -> &'static str {
self.mode.as_str()
}
#[must_use]
pub const fn theme(&self) -> &FastApiTheme {
&self.theme
}
pub fn set_theme(&mut self, theme: FastApiTheme) {
self.theme = theme;
}
pub fn success(&self, message: &str) {
self.status(StatusKind::Success, message);
}
pub fn error(&self, message: &str) {
self.status(StatusKind::Error, message);
}
pub fn warning(&self, message: &str) {
self.status(StatusKind::Warning, message);
}
pub fn info(&self, message: &str) {
self.status(StatusKind::Info, message);
}
pub fn debug(&self, message: &str) {
self.status(StatusKind::Debug, message);
}
pub fn status(&self, kind: StatusKind, message: &str) {
if self.mode == OutputMode::Minimal && kind == StatusKind::Debug {
return;
}
let (level, plain, raw, use_stderr) = self.format_status(kind, message);
Self::write_line(level, &plain, &raw, use_stderr);
}
fn format_status(
&self,
kind: StatusKind,
message: &str,
) -> (OutputLevel, String, String, bool) {
let plain = format!("{} {}", kind.plain_prefix(), message);
let level = kind.level();
let use_stderr = kind.use_stderr();
match self.mode {
OutputMode::Plain => (level, plain.clone(), plain, use_stderr),
OutputMode::Minimal => {
let color = kind.color(&self.theme).to_ansi_fg();
let raw = format!("{color}{}{} {message}", kind.plain_prefix(), ANSI_RESET);
(level, plain, raw, use_stderr)
}
OutputMode::Rich => {
let color = kind.color(&self.theme).to_ansi_fg();
let icon = kind.rich_icon();
let raw = format!("{color}{icon}{ANSI_RESET} {message}");
(level, plain, raw, use_stderr)
}
}
}
pub fn rule(&self, title: Option<&str>) {
let plain = match title {
Some(value) => format!("--- {value} ---"),
None => "---".to_string(),
};
let raw = if self.mode.uses_ansi() {
format!("{}{}{}", self.theme.border.to_ansi_fg(), plain, ANSI_RESET)
} else {
plain.clone()
};
Self::write_line(OutputLevel::Info, &plain, &raw, false);
}
pub fn panel(&self, content: &str, title: Option<&str>) {
let plain = match title {
Some(value) => format!("[{value}]\n{content}"),
None => content.to_string(),
};
let raw = if self.mode.uses_ansi() {
match title {
Some(value) => format!(
"{}[{}]{}\n{content}",
self.theme.header.to_ansi_fg(),
value,
ANSI_RESET
),
None => content.to_string(),
}
} else {
plain.clone()
};
Self::write_line(OutputLevel::Info, &plain, &raw, false);
}
pub fn print(&self, text: &str) {
Self::write_line(OutputLevel::Info, text, text, false);
}
pub fn with_test_output<F: FnOnce()>(test: &TestOutput, f: F) {
TEST_OUTPUT.with(|cell| {
*cell.borrow_mut() = Some(test.clone());
});
f();
TEST_OUTPUT.with(|cell| {
*cell.borrow_mut() = None;
});
}
fn write_line(level: OutputLevel, content: &str, raw: &str, use_stderr: bool) {
let captured = TEST_OUTPUT.with(|cell| {
if let Some(test_output) = cell.borrow().as_ref() {
let entry = OutputEntry {
content: content.to_string(),
timestamp: Instant::now(),
level,
component: None,
raw_ansi: raw.to_string(),
};
test_output.push(entry);
true
} else {
false
}
});
if captured {
return;
}
if use_stderr {
eprintln!("{raw}");
} else {
println!("{raw}");
}
}
pub fn global() -> parking_lot::RwLockReadGuard<'static, RichOutput> {
get_global()
}
pub fn global_mut() -> parking_lot::RwLockWriteGuard<'static, RichOutput> {
GLOBAL_OUTPUT.write()
}
}
impl Default for RichOutput {
fn default() -> Self {
Self::auto()
}
}
pub struct RichOutputBuilder {
mode: Option<OutputMode>,
theme: Option<FastApiTheme>,
}
impl RichOutputBuilder {
#[must_use]
pub fn new() -> Self {
Self {
mode: None,
theme: None,
}
}
#[must_use]
pub fn mode(mut self, mode: OutputMode) -> Self {
self.mode = Some(mode);
self
}
#[must_use]
pub fn theme(mut self, theme: FastApiTheme) -> Self {
self.theme = Some(theme);
self
}
#[must_use]
pub fn build(self) -> RichOutput {
let mode = self.mode.unwrap_or_else(OutputMode::auto);
let mut output = RichOutput::with_mode(mode);
if let Some(theme) = self.theme {
output.set_theme(theme);
}
output
}
}
impl Default for RichOutputBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusKind {
Success,
Error,
Warning,
Info,
Debug,
Pending,
InProgress,
}
impl StatusKind {
#[must_use]
pub const fn plain_prefix(&self) -> &'static str {
match self {
Self::Success => "[OK]",
Self::Error => "[ERROR]",
Self::Warning => "[WARN]",
Self::Info => "[INFO]",
Self::Debug => "[DEBUG]",
Self::Pending => "[PENDING]",
Self::InProgress => "[...]",
}
}
#[must_use]
pub const fn rich_icon(&self) -> &'static str {
match self {
Self::Success => "✓",
Self::Error => "✗",
Self::Warning => "⚠",
Self::Info => "ℹ",
Self::Debug => "●",
Self::Pending => "○",
Self::InProgress => "◐",
}
}
#[must_use]
pub const fn level(&self) -> OutputLevel {
match self {
Self::Success => OutputLevel::Success,
Self::Error => OutputLevel::Error,
Self::Warning => OutputLevel::Warning,
Self::Info | Self::Pending | Self::InProgress => OutputLevel::Info,
Self::Debug => OutputLevel::Debug,
}
}
#[must_use]
pub const fn use_stderr(&self) -> bool {
matches!(self, Self::Error | Self::Warning)
}
fn color(self, theme: &FastApiTheme) -> crate::themes::Color {
match self {
Self::Success => theme.success,
Self::Error => theme.error,
Self::Warning => theme.warning,
Self::Info => theme.info,
Self::Debug | Self::Pending => theme.muted,
Self::InProgress => theme.accent,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{assert_contains, assert_has_ansi, assert_no_ansi};
use serial_test::serial;
#[test]
fn test_rich_output_new() {
let output = RichOutput::new(OutputMode::Plain);
assert_eq!(output.mode(), OutputMode::Plain);
}
#[test]
fn test_rich_output_mode_setters() {
let rich = RichOutput::rich();
assert_eq!(rich.mode(), OutputMode::Rich);
let plain = RichOutput::plain();
assert_eq!(plain.mode(), OutputMode::Plain);
}
#[test]
fn test_rich_output_set_mode() {
let mut output = RichOutput::rich();
output.set_mode(OutputMode::Plain);
assert_eq!(output.mode(), OutputMode::Plain);
}
#[test]
fn test_builder_with_mode() {
let output = RichOutput::builder().mode(OutputMode::Minimal).build();
assert_eq!(output.mode(), OutputMode::Minimal);
}
#[test]
fn test_builder_with_theme() {
let theme = FastApiTheme::neon();
let output = RichOutput::builder()
.mode(OutputMode::Plain)
.theme(theme.clone())
.build();
assert_eq!(output.theme(), &theme);
}
#[test]
fn test_status_plain_success() {
let output = RichOutput::plain();
let test_output = TestOutput::new(OutputMode::Plain);
RichOutput::with_test_output(&test_output, || {
output.success("Operation completed");
});
let captured = test_output.captured();
assert_contains(&captured, "[OK]");
assert_contains(&captured, "Operation completed");
assert_no_ansi(&captured);
}
#[test]
fn test_status_rich_has_ansi() {
let output = RichOutput::rich();
let test_output = TestOutput::new(OutputMode::Rich);
RichOutput::with_test_output(&test_output, || {
output.info("Server starting");
});
let raw = test_output.captured_raw();
assert_contains(&raw, "Server starting");
assert_has_ansi(&raw);
}
#[test]
fn test_rule_and_panel_capture() {
let output = RichOutput::plain();
let test_output = TestOutput::new(OutputMode::Plain);
RichOutput::with_test_output(&test_output, || {
output.rule(Some("Configuration"));
output.panel("Content", Some("Title"));
});
let captured = test_output.captured();
assert_contains(&captured, "Configuration");
assert_contains(&captured, "[Title]");
}
#[test]
fn test_print_capture() {
let output = RichOutput::plain();
let test_output = TestOutput::new(OutputMode::Plain);
RichOutput::with_test_output(&test_output, || {
output.print("Raw text");
});
let captured = test_output.captured();
assert_contains(&captured, "Raw text");
}
#[test]
#[serial]
fn test_get_set_global() {
let original = RichOutput::global().clone();
set_global(RichOutput::plain());
assert_eq!(get_global().mode(), OutputMode::Plain);
set_global(original);
}
#[test]
fn test_is_agent_mode_plain() {
let output = RichOutput::plain();
assert!(output.is_agent_mode());
}
#[test]
fn test_is_agent_mode_rich() {
let output = RichOutput::rich();
assert!(!output.is_agent_mode());
}
#[test]
fn test_is_agent_mode_minimal() {
let output = RichOutput::new(OutputMode::Minimal);
assert!(!output.is_agent_mode());
}
#[test]
fn test_mode_name_plain() {
let output = RichOutput::plain();
assert_eq!(output.mode_name(), "plain");
}
#[test]
fn test_mode_name_rich() {
let output = RichOutput::rich();
assert_eq!(output.mode_name(), "rich");
}
#[test]
fn test_mode_name_minimal() {
let output = RichOutput::new(OutputMode::Minimal);
assert_eq!(output.mode_name(), "minimal");
}
}