Skip to main content

fastapi_output/
facade.rs

1//! Rich output facade.
2//!
3//! Provides a unified interface for console output that automatically
4//! adapts to the current environment.
5
6use crate::mode::OutputMode;
7use crate::testing::{OutputEntry, OutputLevel, TestOutput};
8use crate::themes::FastApiTheme;
9use parking_lot::RwLock;
10use std::cell::RefCell;
11use std::sync::LazyLock;
12use std::time::Instant;
13
14const ANSI_RESET: &str = "\x1b[0m";
15
16/// Global instance of `RichOutput` for convenient access.
17static GLOBAL_OUTPUT: LazyLock<RwLock<RichOutput>> =
18    LazyLock::new(|| RwLock::new(RichOutput::auto()));
19
20thread_local! {
21    static TEST_OUTPUT: RefCell<Option<TestOutput>> = const { RefCell::new(None) };
22}
23
24/// Get the global `RichOutput` instance.
25///
26/// # Panics
27///
28pub fn get_global() -> parking_lot::RwLockReadGuard<'static, RichOutput> {
29    GLOBAL_OUTPUT.read()
30}
31
32/// Replace the global `RichOutput` instance.
33///
34/// # Panics
35///
36pub fn set_global(output: RichOutput) {
37    *GLOBAL_OUTPUT.write() = output;
38}
39
40/// The main facade for rich console output.
41///
42/// Provides methods for printing styled text, tables, panels,
43/// and other visual elements. Automatically adapts output based
44/// on the detected environment.
45#[derive(Debug, Clone)]
46pub struct RichOutput {
47    mode: OutputMode,
48    theme: FastApiTheme,
49}
50
51impl RichOutput {
52    /// Create a new `RichOutput` with the specified mode and default theme.
53    #[must_use]
54    pub fn new(mode: OutputMode) -> Self {
55        Self {
56            mode,
57            theme: FastApiTheme::default(),
58        }
59    }
60
61    /// Create a new `RichOutput` with the specified mode.
62    #[must_use]
63    pub fn with_mode(mode: OutputMode) -> Self {
64        Self::new(mode)
65    }
66
67    /// Create a new `RichOutput` with auto-detected mode.
68    #[must_use]
69    pub fn auto() -> Self {
70        Self::new(OutputMode::auto())
71    }
72
73    /// Create a new `RichOutput` with rich mode (for humans).
74    #[must_use]
75    pub fn rich() -> Self {
76        Self::new(OutputMode::Rich)
77    }
78
79    /// Create a new `RichOutput` with plain mode (for agents).
80    #[must_use]
81    pub fn plain() -> Self {
82        Self::new(OutputMode::Plain)
83    }
84
85    /// Create a builder for custom configuration.
86    #[must_use]
87    pub fn builder() -> RichOutputBuilder {
88        RichOutputBuilder::new()
89    }
90
91    /// Get the current output mode.
92    #[must_use]
93    pub const fn mode(&self) -> OutputMode {
94        self.mode
95    }
96
97    /// Set the output mode.
98    pub fn set_mode(&mut self, mode: OutputMode) {
99        self.mode = mode;
100    }
101
102    /// Check if running in agent-friendly mode (plain text output).
103    ///
104    /// Returns `true` if the output mode is `Plain`, which is the mode
105    /// used when an AI agent environment is detected or explicitly requested.
106    ///
107    /// # Example
108    ///
109    /// ```rust
110    /// use fastapi_output::prelude::*;
111    ///
112    /// let output = RichOutput::plain();
113    /// assert!(output.is_agent_mode());
114    ///
115    /// let output = RichOutput::rich();
116    /// assert!(!output.is_agent_mode());
117    /// ```
118    #[must_use]
119    pub const fn is_agent_mode(&self) -> bool {
120        matches!(self.mode, OutputMode::Plain)
121    }
122
123    /// Get the mode name as a string for logging/debugging.
124    ///
125    /// Returns one of: `"rich"`, `"plain"`, or `"minimal"`.
126    ///
127    /// # Example
128    ///
129    /// ```rust
130    /// use fastapi_output::prelude::*;
131    ///
132    /// let output = RichOutput::plain();
133    /// assert_eq!(output.mode_name(), "plain");
134    /// ```
135    #[must_use]
136    pub const fn mode_name(&self) -> &'static str {
137        self.mode.as_str()
138    }
139
140    /// Get the current theme.
141    #[must_use]
142    pub const fn theme(&self) -> &FastApiTheme {
143        &self.theme
144    }
145
146    /// Set the theme.
147    pub fn set_theme(&mut self, theme: FastApiTheme) {
148        self.theme = theme;
149    }
150
151    /// Print a success message.
152    ///
153    /// In rich mode: Green checkmark with styled text.
154    /// In plain mode: `[OK] message`
155    pub fn success(&self, message: &str) {
156        self.status(StatusKind::Success, message);
157    }
158
159    /// Print an error message.
160    ///
161    /// In rich mode: Red X with styled text.
162    /// In plain mode: `[ERROR] message`
163    pub fn error(&self, message: &str) {
164        self.status(StatusKind::Error, message);
165    }
166
167    /// Print a warning message.
168    ///
169    /// In rich mode: Yellow warning symbol with styled text.
170    /// In plain mode: `[WARN] message`
171    pub fn warning(&self, message: &str) {
172        self.status(StatusKind::Warning, message);
173    }
174
175    /// Print an info message.
176    ///
177    /// In rich mode: Blue info symbol with styled text.
178    /// In plain mode: `[INFO] message`
179    pub fn info(&self, message: &str) {
180        self.status(StatusKind::Info, message);
181    }
182
183    /// Print a debug message (only in non-minimal modes).
184    ///
185    /// In rich mode: Gray text.
186    /// In plain mode: `[DEBUG] message`
187    /// In minimal mode: Nothing printed.
188    pub fn debug(&self, message: &str) {
189        self.status(StatusKind::Debug, message);
190    }
191
192    /// Print a status message with the given kind.
193    pub fn status(&self, kind: StatusKind, message: &str) {
194        if self.mode == OutputMode::Minimal && kind == StatusKind::Debug {
195            return;
196        }
197
198        let (level, plain, raw, use_stderr) = self.format_status(kind, message);
199        Self::write_line(level, &plain, &raw, use_stderr);
200    }
201
202    fn format_status(
203        &self,
204        kind: StatusKind,
205        message: &str,
206    ) -> (OutputLevel, String, String, bool) {
207        let plain = format!("{} {}", kind.plain_prefix(), message);
208        let level = kind.level();
209        let use_stderr = kind.use_stderr();
210
211        match self.mode {
212            OutputMode::Plain => (level, plain.clone(), plain, use_stderr),
213            OutputMode::Minimal => {
214                let color = kind.color(&self.theme).to_ansi_fg();
215                let raw = format!("{color}{}{} {message}", kind.plain_prefix(), ANSI_RESET);
216                (level, plain, raw, use_stderr)
217            }
218            OutputMode::Rich => {
219                let color = kind.color(&self.theme).to_ansi_fg();
220                let icon = kind.rich_icon();
221                let raw = format!("{color}{icon}{ANSI_RESET} {message}");
222                (level, plain, raw, use_stderr)
223            }
224        }
225    }
226
227    /// Print a horizontal rule/divider.
228    pub fn rule(&self, title: Option<&str>) {
229        let plain = match title {
230            Some(value) => format!("--- {value} ---"),
231            None => "---".to_string(),
232        };
233
234        let raw = if self.mode.uses_ansi() {
235            format!("{}{}{}", self.theme.border.to_ansi_fg(), plain, ANSI_RESET)
236        } else {
237            plain.clone()
238        };
239
240        Self::write_line(OutputLevel::Info, &plain, &raw, false);
241    }
242
243    /// Print content in a panel/box.
244    pub fn panel(&self, content: &str, title: Option<&str>) {
245        let plain = match title {
246            Some(value) => format!("[{value}]\n{content}"),
247            None => content.to_string(),
248        };
249
250        let raw = if self.mode.uses_ansi() {
251            match title {
252                Some(value) => format!(
253                    "{}[{}]{}\n{content}",
254                    self.theme.header.to_ansi_fg(),
255                    value,
256                    ANSI_RESET
257                ),
258                None => content.to_string(),
259            }
260        } else {
261            plain.clone()
262        };
263
264        Self::write_line(OutputLevel::Info, &plain, &raw, false);
265    }
266
267    /// Print raw text.
268    pub fn print(&self, text: &str) {
269        Self::write_line(OutputLevel::Info, text, text, false);
270    }
271
272    /// Run a closure with test output capture enabled.
273    pub fn with_test_output<F: FnOnce()>(test: &TestOutput, f: F) {
274        TEST_OUTPUT.with(|cell| {
275            *cell.borrow_mut() = Some(test.clone());
276        });
277        f();
278        TEST_OUTPUT.with(|cell| {
279            *cell.borrow_mut() = None;
280        });
281    }
282
283    fn write_line(level: OutputLevel, content: &str, raw: &str, use_stderr: bool) {
284        let captured = TEST_OUTPUT.with(|cell| {
285            if let Some(test_output) = cell.borrow().as_ref() {
286                let entry = OutputEntry {
287                    content: content.to_string(),
288                    timestamp: Instant::now(),
289                    level,
290                    component: None,
291                    raw_ansi: raw.to_string(),
292                };
293                test_output.push(entry);
294                true
295            } else {
296                false
297            }
298        });
299
300        if captured {
301            return;
302        }
303
304        if use_stderr {
305            eprintln!("{raw}");
306        } else {
307            println!("{raw}");
308        }
309    }
310
311    /// Get the global `RichOutput` instance.
312    ///
313    /// # Panics
314    ///
315    pub fn global() -> parking_lot::RwLockReadGuard<'static, RichOutput> {
316        get_global()
317    }
318
319    /// Get mutable access to the global `RichOutput` instance.
320    pub fn global_mut() -> parking_lot::RwLockWriteGuard<'static, RichOutput> {
321        GLOBAL_OUTPUT.write()
322    }
323}
324
325impl Default for RichOutput {
326    fn default() -> Self {
327        Self::auto()
328    }
329}
330
331/// Builder for RichOutput with custom configuration.
332pub struct RichOutputBuilder {
333    mode: Option<OutputMode>,
334    theme: Option<FastApiTheme>,
335}
336
337impl RichOutputBuilder {
338    /// Create a new builder with defaults.
339    #[must_use]
340    pub fn new() -> Self {
341        Self {
342            mode: None,
343            theme: None,
344        }
345    }
346
347    /// Set the output mode.
348    #[must_use]
349    pub fn mode(mut self, mode: OutputMode) -> Self {
350        self.mode = Some(mode);
351        self
352    }
353
354    /// Set the output theme.
355    #[must_use]
356    pub fn theme(mut self, theme: FastApiTheme) -> Self {
357        self.theme = Some(theme);
358        self
359    }
360
361    /// Build the configured `RichOutput`.
362    #[must_use]
363    pub fn build(self) -> RichOutput {
364        let mode = self.mode.unwrap_or_else(OutputMode::auto);
365        let mut output = RichOutput::with_mode(mode);
366        if let Some(theme) = self.theme {
367            output.set_theme(theme);
368        }
369        output
370    }
371}
372
373impl Default for RichOutputBuilder {
374    fn default() -> Self {
375        Self::new()
376    }
377}
378
379/// Status message kinds.
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub enum StatusKind {
382    /// Success message.
383    Success,
384    /// Error message.
385    Error,
386    /// Warning message.
387    Warning,
388    /// Informational message.
389    Info,
390    /// Debug message.
391    Debug,
392    /// Pending status.
393    Pending,
394    /// In-progress status.
395    InProgress,
396}
397
398impl StatusKind {
399    /// Get the plain prefix used for this status kind.
400    #[must_use]
401    pub const fn plain_prefix(&self) -> &'static str {
402        match self {
403            Self::Success => "[OK]",
404            Self::Error => "[ERROR]",
405            Self::Warning => "[WARN]",
406            Self::Info => "[INFO]",
407            Self::Debug => "[DEBUG]",
408            Self::Pending => "[PENDING]",
409            Self::InProgress => "[...]",
410        }
411    }
412
413    /// Get the icon used for rich mode output.
414    #[must_use]
415    pub const fn rich_icon(&self) -> &'static str {
416        match self {
417            Self::Success => "✓",
418            Self::Error => "✗",
419            Self::Warning => "⚠",
420            Self::Info => "ℹ",
421            Self::Debug => "●",
422            Self::Pending => "○",
423            Self::InProgress => "◐",
424        }
425    }
426
427    /// Map to the output level for capture.
428    #[must_use]
429    pub const fn level(&self) -> OutputLevel {
430        match self {
431            Self::Success => OutputLevel::Success,
432            Self::Error => OutputLevel::Error,
433            Self::Warning => OutputLevel::Warning,
434            Self::Info | Self::Pending | Self::InProgress => OutputLevel::Info,
435            Self::Debug => OutputLevel::Debug,
436        }
437    }
438
439    /// Whether this status should be printed to stderr.
440    #[must_use]
441    pub const fn use_stderr(&self) -> bool {
442        matches!(self, Self::Error | Self::Warning)
443    }
444
445    fn color(self, theme: &FastApiTheme) -> crate::themes::Color {
446        match self {
447            Self::Success => theme.success,
448            Self::Error => theme.error,
449            Self::Warning => theme.warning,
450            Self::Info => theme.info,
451            Self::Debug | Self::Pending => theme.muted,
452            Self::InProgress => theme.accent,
453        }
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::testing::{assert_contains, assert_has_ansi, assert_no_ansi};
461    use serial_test::serial;
462
463    #[test]
464    fn test_rich_output_new() {
465        let output = RichOutput::new(OutputMode::Plain);
466        assert_eq!(output.mode(), OutputMode::Plain);
467    }
468
469    #[test]
470    fn test_rich_output_mode_setters() {
471        let rich = RichOutput::rich();
472        assert_eq!(rich.mode(), OutputMode::Rich);
473
474        let plain = RichOutput::plain();
475        assert_eq!(plain.mode(), OutputMode::Plain);
476    }
477
478    #[test]
479    fn test_rich_output_set_mode() {
480        let mut output = RichOutput::rich();
481        output.set_mode(OutputMode::Plain);
482        assert_eq!(output.mode(), OutputMode::Plain);
483    }
484
485    #[test]
486    fn test_builder_with_mode() {
487        let output = RichOutput::builder().mode(OutputMode::Minimal).build();
488        assert_eq!(output.mode(), OutputMode::Minimal);
489    }
490
491    #[test]
492    fn test_builder_with_theme() {
493        let theme = FastApiTheme::neon();
494        let output = RichOutput::builder()
495            .mode(OutputMode::Plain)
496            .theme(theme.clone())
497            .build();
498        assert_eq!(output.theme(), &theme);
499    }
500
501    #[test]
502    fn test_status_plain_success() {
503        let output = RichOutput::plain();
504        let test_output = TestOutput::new(OutputMode::Plain);
505        RichOutput::with_test_output(&test_output, || {
506            output.success("Operation completed");
507        });
508        let captured = test_output.captured();
509        assert_contains(&captured, "[OK]");
510        assert_contains(&captured, "Operation completed");
511        assert_no_ansi(&captured);
512    }
513
514    #[test]
515    fn test_status_rich_has_ansi() {
516        let output = RichOutput::rich();
517        let test_output = TestOutput::new(OutputMode::Rich);
518        RichOutput::with_test_output(&test_output, || {
519            output.info("Server starting");
520        });
521        let raw = test_output.captured_raw();
522        assert_contains(&raw, "Server starting");
523        assert_has_ansi(&raw);
524    }
525
526    #[test]
527    fn test_rule_and_panel_capture() {
528        let output = RichOutput::plain();
529        let test_output = TestOutput::new(OutputMode::Plain);
530        RichOutput::with_test_output(&test_output, || {
531            output.rule(Some("Configuration"));
532            output.panel("Content", Some("Title"));
533        });
534        let captured = test_output.captured();
535        assert_contains(&captured, "Configuration");
536        assert_contains(&captured, "[Title]");
537    }
538
539    #[test]
540    fn test_print_capture() {
541        let output = RichOutput::plain();
542        let test_output = TestOutput::new(OutputMode::Plain);
543        RichOutput::with_test_output(&test_output, || {
544            output.print("Raw text");
545        });
546        let captured = test_output.captured();
547        assert_contains(&captured, "Raw text");
548    }
549
550    #[test]
551    #[serial]
552    fn test_get_set_global() {
553        let original = RichOutput::global().clone();
554        set_global(RichOutput::plain());
555        assert_eq!(get_global().mode(), OutputMode::Plain);
556        set_global(original);
557    }
558
559    // ========== IS_AGENT_MODE TESTS ==========
560
561    #[test]
562    fn test_is_agent_mode_plain() {
563        let output = RichOutput::plain();
564        assert!(output.is_agent_mode());
565    }
566
567    #[test]
568    fn test_is_agent_mode_rich() {
569        let output = RichOutput::rich();
570        assert!(!output.is_agent_mode());
571    }
572
573    #[test]
574    fn test_is_agent_mode_minimal() {
575        let output = RichOutput::new(OutputMode::Minimal);
576        assert!(!output.is_agent_mode());
577    }
578
579    // ========== MODE_NAME TESTS ==========
580
581    #[test]
582    fn test_mode_name_plain() {
583        let output = RichOutput::plain();
584        assert_eq!(output.mode_name(), "plain");
585    }
586
587    #[test]
588    fn test_mode_name_rich() {
589        let output = RichOutput::rich();
590        assert_eq!(output.mode_name(), "rich");
591    }
592
593    #[test]
594    fn test_mode_name_minimal() {
595        let output = RichOutput::new(OutputMode::Minimal);
596        assert_eq!(output.mode_name(), "minimal");
597    }
598}