1use 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
16static 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
24pub fn get_global() -> parking_lot::RwLockReadGuard<'static, RichOutput> {
29 GLOBAL_OUTPUT.read()
30}
31
32pub fn set_global(output: RichOutput) {
37 *GLOBAL_OUTPUT.write() = output;
38}
39
40#[derive(Debug, Clone)]
46pub struct RichOutput {
47 mode: OutputMode,
48 theme: FastApiTheme,
49}
50
51impl RichOutput {
52 #[must_use]
54 pub fn new(mode: OutputMode) -> Self {
55 Self {
56 mode,
57 theme: FastApiTheme::default(),
58 }
59 }
60
61 #[must_use]
63 pub fn with_mode(mode: OutputMode) -> Self {
64 Self::new(mode)
65 }
66
67 #[must_use]
69 pub fn auto() -> Self {
70 Self::new(OutputMode::auto())
71 }
72
73 #[must_use]
75 pub fn rich() -> Self {
76 Self::new(OutputMode::Rich)
77 }
78
79 #[must_use]
81 pub fn plain() -> Self {
82 Self::new(OutputMode::Plain)
83 }
84
85 #[must_use]
87 pub fn builder() -> RichOutputBuilder {
88 RichOutputBuilder::new()
89 }
90
91 #[must_use]
93 pub const fn mode(&self) -> OutputMode {
94 self.mode
95 }
96
97 pub fn set_mode(&mut self, mode: OutputMode) {
99 self.mode = mode;
100 }
101
102 #[must_use]
119 pub const fn is_agent_mode(&self) -> bool {
120 matches!(self.mode, OutputMode::Plain)
121 }
122
123 #[must_use]
136 pub const fn mode_name(&self) -> &'static str {
137 self.mode.as_str()
138 }
139
140 #[must_use]
142 pub const fn theme(&self) -> &FastApiTheme {
143 &self.theme
144 }
145
146 pub fn set_theme(&mut self, theme: FastApiTheme) {
148 self.theme = theme;
149 }
150
151 pub fn success(&self, message: &str) {
156 self.status(StatusKind::Success, message);
157 }
158
159 pub fn error(&self, message: &str) {
164 self.status(StatusKind::Error, message);
165 }
166
167 pub fn warning(&self, message: &str) {
172 self.status(StatusKind::Warning, message);
173 }
174
175 pub fn info(&self, message: &str) {
180 self.status(StatusKind::Info, message);
181 }
182
183 pub fn debug(&self, message: &str) {
189 self.status(StatusKind::Debug, message);
190 }
191
192 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 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 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 pub fn print(&self, text: &str) {
269 Self::write_line(OutputLevel::Info, text, text, false);
270 }
271
272 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 pub fn global() -> parking_lot::RwLockReadGuard<'static, RichOutput> {
316 get_global()
317 }
318
319 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
331pub struct RichOutputBuilder {
333 mode: Option<OutputMode>,
334 theme: Option<FastApiTheme>,
335}
336
337impl RichOutputBuilder {
338 #[must_use]
340 pub fn new() -> Self {
341 Self {
342 mode: None,
343 theme: None,
344 }
345 }
346
347 #[must_use]
349 pub fn mode(mut self, mode: OutputMode) -> Self {
350 self.mode = Some(mode);
351 self
352 }
353
354 #[must_use]
356 pub fn theme(mut self, theme: FastApiTheme) -> Self {
357 self.theme = Some(theme);
358 self
359 }
360
361 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub enum StatusKind {
382 Success,
384 Error,
386 Warning,
388 Info,
390 Debug,
392 Pending,
394 InProgress,
396}
397
398impl StatusKind {
399 #[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 #[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 #[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 #[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 #[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 #[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}