sqlmodel_console/console.rs
1//! SqlModelConsole - Main coordinator for console output.
2//!
3//! This module provides the central `SqlModelConsole` struct that coordinates
4//! all output rendering. It automatically adapts to the detected output mode
5//! and provides a consistent API for all console operations.
6//!
7//! # Stream Separation
8//!
9//! - `print()` → stdout (semantic data for agents to parse)
10//! - `status()`, `success()`, `error()`, etc. → stderr (human feedback)
11//!
12//! # Markup Syntax
13//!
14//! In rich mode, text can use markup syntax: `[bold red]text[/]`
15//! In plain mode, markup is automatically stripped.
16//!
17//! # Example
18//!
19//! ```rust
20//! use sqlmodel_console::{SqlModelConsole, OutputMode};
21//!
22//! let console = SqlModelConsole::new();
23//!
24//! // Mode-aware output
25//! console.print("Regular output");
26//! console.success("Operation completed");
27//! console.error("Something went wrong");
28//! ```
29
30use crate::mode::OutputMode;
31use crate::theme::Theme;
32
33/// Main coordinator for all SQLModel console output.
34///
35/// `SqlModelConsole` provides a unified API for rendering output that
36/// automatically adapts to the detected output mode (Plain, Rich, or Json).
37///
38/// # Example
39///
40/// ```rust
41/// use sqlmodel_console::{SqlModelConsole, OutputMode};
42///
43/// let console = SqlModelConsole::new();
44/// console.print("Hello, world!");
45/// console.status("Processing...");
46/// console.success("Done!");
47/// ```
48#[derive(Debug, Clone)]
49pub struct SqlModelConsole {
50 /// Current output mode.
51 mode: OutputMode,
52 /// Color theme.
53 theme: Theme,
54 /// Default width for plain mode rules and formatting.
55 plain_width: usize,
56 // Note: We intentionally don't store rich_rust::Console here because it contains
57 // Cell/RefCell types that are not Sync. Instead, rich output is created on-demand
58 // in methods that need it. This allows SqlModelConsole to be Send+Sync for use
59 // in global statics and cross-thread sharing.
60}
61
62impl SqlModelConsole {
63 /// Create a new console with auto-detected mode and default theme.
64 ///
65 /// This is the recommended way to create a console. It will:
66 /// 1. Check environment variables for explicit mode
67 /// 2. Detect AI agent environments
68 /// 3. Check terminal capabilities
69 /// 4. Choose appropriate mode
70 #[must_use]
71 pub fn new() -> Self {
72 Self {
73 mode: OutputMode::detect(),
74 theme: Theme::default(),
75 plain_width: 80,
76 }
77 }
78
79 /// Create a console with a specific output mode.
80 ///
81 /// Use this when you need to force a specific mode regardless of environment.
82 #[must_use]
83 pub fn with_mode(mode: OutputMode) -> Self {
84 Self {
85 mode,
86 theme: Theme::default(),
87 plain_width: 80,
88 }
89 }
90
91 /// Create a console with a specific theme.
92 #[must_use]
93 pub fn with_theme(theme: Theme) -> Self {
94 Self {
95 mode: OutputMode::detect(),
96 theme,
97 plain_width: 80,
98 }
99 }
100
101 /// Builder method to set the theme.
102 #[must_use]
103 pub fn theme(mut self, theme: Theme) -> Self {
104 self.theme = theme;
105 self
106 }
107
108 /// Builder method to set the plain mode width.
109 #[must_use]
110 pub fn plain_width(mut self, width: usize) -> Self {
111 self.plain_width = width;
112 self
113 }
114
115 /// Get the current output mode.
116 #[must_use]
117 pub const fn mode(&self) -> OutputMode {
118 self.mode
119 }
120
121 /// Get the current theme.
122 #[must_use]
123 pub const fn get_theme(&self) -> &Theme {
124 &self.theme
125 }
126
127 /// Get the plain mode width.
128 #[must_use]
129 pub const fn get_plain_width(&self) -> usize {
130 self.plain_width
131 }
132
133 /// Set the output mode.
134 pub fn set_mode(&mut self, mode: OutputMode) {
135 self.mode = mode;
136 }
137
138 /// Set the theme.
139 pub fn set_theme(&mut self, theme: Theme) {
140 self.theme = theme;
141 }
142
143 /// Check if rich output is active.
144 #[must_use]
145 pub fn is_rich(&self) -> bool {
146 self.mode == OutputMode::Rich
147 }
148
149 /// Check if plain output is active.
150 #[must_use]
151 pub fn is_plain(&self) -> bool {
152 self.mode == OutputMode::Plain
153 }
154
155 /// Check if JSON output is active.
156 #[must_use]
157 pub fn is_json(&self) -> bool {
158 self.mode == OutputMode::Json
159 }
160
161 // =========================================================================
162 // Basic Output Methods
163 // =========================================================================
164
165 /// Print a message to stdout.
166 ///
167 /// In rich mode, supports markup syntax: `[bold red]text[/]`
168 /// In plain mode, prints without formatting (markup stripped).
169 /// In JSON mode, regular prints go to stderr to keep stdout clean.
170 pub fn print(&self, message: &str) {
171 match self.mode {
172 OutputMode::Rich => {
173 // Note: Falls back to plain output until rich terminal library is integrated
174 println!("{}", strip_markup(message));
175 }
176 OutputMode::Plain => {
177 println!("{}", strip_markup(message));
178 }
179 OutputMode::Json => {
180 // In JSON mode, regular prints go to stderr to keep stdout for JSON
181 eprintln!("{}", strip_markup(message));
182 }
183 }
184 }
185
186 /// Print to stdout without any markup processing.
187 ///
188 /// Use this when you need raw output without markup stripping.
189 pub fn print_raw(&self, message: &str) {
190 println!("{message}");
191 }
192
193 /// Print a message followed by a newline to stderr.
194 ///
195 /// Status messages are always sent to stderr because:
196 /// - Agents typically only parse stdout
197 /// - Status messages are transient/informational
198 /// - Separating streams helps with output redirection
199 pub fn status(&self, message: &str) {
200 match self.mode {
201 OutputMode::Rich => {
202 // Note: Falls back to plain output until rich terminal library is integrated
203 eprintln!("{}", strip_markup(message));
204 }
205 OutputMode::Plain | OutputMode::Json => {
206 eprintln!("{}", strip_markup(message));
207 }
208 }
209 }
210
211 /// Print a success message (green with checkmark).
212 pub fn success(&self, message: &str) {
213 self.print_styled_status(message, "green", "\u{2713}"); // ✓
214 }
215
216 /// Print an error message (red with X).
217 pub fn error(&self, message: &str) {
218 self.print_styled_status(message, "red bold", "\u{2717}"); // ✗
219 }
220
221 /// Print a warning message (yellow with warning sign).
222 pub fn warning(&self, message: &str) {
223 self.print_styled_status(message, "yellow", "\u{26A0}"); // ⚠
224 }
225
226 /// Print an info message (cyan with info symbol).
227 pub fn info(&self, message: &str) {
228 self.print_styled_status(message, "cyan", "\u{2139}"); // ℹ
229 }
230
231 fn print_styled_status(&self, message: &str, _style: &str, icon: &str) {
232 match self.mode {
233 OutputMode::Rich => {
234 // Note: Falls back to plain output until rich terminal library is integrated
235 eprintln!("{icon} {message}");
236 }
237 OutputMode::Plain => {
238 // Plain mode: no icons, just the message
239 eprintln!("{message}");
240 }
241 OutputMode::Json => {
242 // JSON mode: include icon for context
243 eprintln!("{icon} {message}");
244 }
245 }
246 }
247
248 // =========================================================================
249 // Horizontal Rules
250 // =========================================================================
251
252 /// Print a horizontal rule/divider.
253 ///
254 /// Optionally includes a title centered in the rule.
255 pub fn rule(&self, title: Option<&str>) {
256 match self.mode {
257 OutputMode::Rich => {
258 // Note: Falls back to plain rule until rich terminal library is integrated
259 self.plain_rule(title);
260 }
261 OutputMode::Plain | OutputMode::Json => {
262 self.plain_rule(title);
263 }
264 }
265 }
266
267 fn plain_rule(&self, title: Option<&str>) {
268 let width = self.plain_width;
269 match title {
270 Some(t) => {
271 let title_len = t.chars().count();
272 if title_len + 4 >= width {
273 // Title too long, just print it
274 eprintln!("-- {t} --");
275 } else {
276 let padding = (width - title_len - 2) / 2;
277 let left = "-".repeat(padding);
278 let right_padding = width - padding - title_len - 2;
279 let right = "-".repeat(right_padding);
280 eprintln!("{left} {t} {right}");
281 }
282 }
283 None => {
284 eprintln!("{}", "-".repeat(width));
285 }
286 }
287 }
288
289 // =========================================================================
290 // JSON Output
291 // =========================================================================
292
293 /// Output JSON to stdout (compact format for parseability).
294 ///
295 /// Returns an error if serialization fails.
296 pub fn print_json<T: serde::Serialize>(&self, value: &T) -> Result<(), serde_json::Error> {
297 let json = serde_json::to_string(value)?;
298 println!("{json}");
299 Ok(())
300 }
301
302 /// Output pretty-printed JSON to stdout.
303 ///
304 /// In rich mode, could include syntax highlighting (not yet implemented).
305 pub fn print_json_pretty<T: serde::Serialize>(
306 &self,
307 value: &T,
308 ) -> Result<(), serde_json::Error> {
309 let json = serde_json::to_string_pretty(value)?;
310 match self.mode {
311 OutputMode::Rich => {
312 #[cfg(feature = "rich")]
313 {
314 // Note: JSON syntax highlighting deferred until rich terminal library is integrated
315 println!("{json}");
316 return Ok(());
317 }
318 #[cfg(not(feature = "rich"))]
319 println!("{json}");
320 }
321 OutputMode::Plain | OutputMode::Json => {
322 println!("{json}");
323 }
324 }
325 Ok(())
326 }
327
328 // =========================================================================
329 // Line/Newline Helpers
330 // =========================================================================
331
332 /// Print an empty line to stdout.
333 pub fn newline(&self) {
334 println!();
335 }
336
337 /// Print an empty line to stderr.
338 pub fn newline_stderr(&self) {
339 eprintln!();
340 }
341}
342
343impl Default for SqlModelConsole {
344 fn default() -> Self {
345 Self::new()
346 }
347}
348
349// =========================================================================
350// Helper Functions
351// =========================================================================
352
353/// Strip markup tags from a string for plain output.
354///
355/// Removes `[tag]...[/]` patterns commonly used in rich markup syntax.
356/// Handles nested tags and preserves literal bracket characters when
357/// they're not part of markup patterns.
358///
359/// A tag is considered markup if:
360/// - It starts with `/` (closing tags: `[/]`, `[/bold]`)
361/// - It contains a space (compound styles: `[red on white]`)
362/// - It has 2+ alphabetic characters (style names: `[bold]`, `[red]`)
363///
364/// This preserves array indices like `[0]`, `[i]`, `[idx]` which are typically
365/// short identifiers without spaces.
366///
367/// # Example
368///
369/// ```rust
370/// use sqlmodel_console::console::strip_markup;
371///
372/// assert_eq!(strip_markup("[bold]text[/]"), "text");
373/// assert_eq!(strip_markup("[red on white]hello[/]"), "hello");
374/// assert_eq!(strip_markup("no markup"), "no markup");
375/// assert_eq!(strip_markup("array[0]"), "array[0]");
376/// ```
377#[must_use]
378pub fn strip_markup(s: &str) -> String {
379 let mut result = String::with_capacity(s.len());
380 let chars: Vec<char> = s.chars().collect();
381 let mut i = 0;
382
383 while i < chars.len() {
384 let c = chars[i];
385
386 if c == '[' {
387 // Look ahead to find the closing ]
388 let mut j = i + 1;
389 let mut found_close = false;
390 let mut close_idx = 0;
391
392 while j < chars.len() {
393 if chars[j] == ']' {
394 found_close = true;
395 close_idx = j;
396 break;
397 }
398 if chars[j] == '[' {
399 // Nested open bracket before close - not a tag
400 break;
401 }
402 j += 1;
403 }
404
405 if found_close {
406 // Extract the tag content
407 let tag_content: String = chars[i + 1..close_idx].iter().collect();
408
409 let is_markup = is_rich_markup_tag(&tag_content);
410
411 if is_markup {
412 // Skip the entire tag
413 i = close_idx + 1;
414 continue;
415 }
416 }
417
418 // Not a markup tag, keep the bracket
419 result.push(c);
420 } else {
421 result.push(c);
422 }
423
424 i += 1;
425 }
426
427 result
428}
429
430#[must_use]
431fn is_rich_markup_tag(tag_content: &str) -> bool {
432 if tag_content.starts_with('/') {
433 return true;
434 }
435 if tag_content.contains(' ') || tag_content.contains('=') {
436 return true;
437 }
438
439 let normalized = tag_content.to_ascii_lowercase();
440 matches!(
441 normalized.as_str(),
442 "bold"
443 | "dim"
444 | "italic"
445 | "underline"
446 | "strike"
447 | "blink"
448 | "reverse"
449 | "black"
450 | "red"
451 | "green"
452 | "yellow"
453 | "blue"
454 | "magenta"
455 | "cyan"
456 | "white"
457 | "default"
458 | "bright_black"
459 | "bright_red"
460 | "bright_green"
461 | "bright_yellow"
462 | "bright_blue"
463 | "bright_magenta"
464 | "bright_cyan"
465 | "bright_white"
466 )
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472
473 #[test]
474 fn test_strip_markup_basic() {
475 assert_eq!(strip_markup("[bold]text[/]"), "text");
476 assert_eq!(strip_markup("[red]hello[/]"), "hello");
477 }
478
479 #[test]
480 fn test_strip_markup_with_style() {
481 assert_eq!(strip_markup("[red on white]hello[/]"), "hello");
482 assert_eq!(strip_markup("[bold italic]styled[/]"), "styled");
483 }
484
485 #[test]
486 fn test_strip_markup_no_markup() {
487 assert_eq!(strip_markup("no markup"), "no markup");
488 assert_eq!(strip_markup("plain text"), "plain text");
489 }
490
491 #[test]
492 fn test_strip_markup_nested() {
493 assert_eq!(strip_markup("[bold][italic]nested[/][/]"), "nested");
494 // Realistic nested tags use style names, not single letters
495 assert_eq!(strip_markup("[red][bold][dim]deep[/][/][/]"), "deep");
496 }
497
498 #[test]
499 fn test_strip_markup_multiple() {
500 assert_eq!(
501 strip_markup("[bold]hello[/] [italic]world[/]"),
502 "hello world"
503 );
504 }
505
506 #[test]
507 fn test_strip_markup_preserves_brackets() {
508 // Unclosed brackets should be preserved
509 assert_eq!(strip_markup("array[0]"), "array[0]");
510 assert_eq!(strip_markup("func(a[i])"), "func(a[i])");
511 assert_eq!(strip_markup("items[idx]"), "items[idx]");
512 assert_eq!(strip_markup("[idx] should stay"), "[idx] should stay");
513 }
514
515 #[test]
516 fn test_strip_markup_strips_known_single_tags() {
517 assert_eq!(strip_markup("[bold]x[/]"), "x");
518 assert_eq!(strip_markup("[red]x[/red]"), "x");
519 }
520
521 #[test]
522 fn test_strip_markup_empty() {
523 assert_eq!(strip_markup(""), "");
524 assert_eq!(strip_markup("[bold][/]"), "");
525 }
526
527 #[test]
528 fn test_console_creation() {
529 let console = SqlModelConsole::new();
530 // Mode depends on environment, so just check it's valid
531 assert!(matches!(
532 console.mode(),
533 OutputMode::Plain | OutputMode::Rich | OutputMode::Json
534 ));
535 }
536
537 #[test]
538 fn test_with_mode() {
539 let console = SqlModelConsole::with_mode(OutputMode::Plain);
540 assert!(console.is_plain());
541 assert!(!console.is_rich());
542 assert!(!console.is_json());
543
544 let console = SqlModelConsole::with_mode(OutputMode::Rich);
545 assert!(console.is_rich());
546 assert!(!console.is_plain());
547
548 let console = SqlModelConsole::with_mode(OutputMode::Json);
549 assert!(console.is_json());
550 }
551
552 #[test]
553 fn test_with_theme() {
554 let light_theme = Theme::light();
555 let console = SqlModelConsole::with_theme(light_theme.clone());
556 assert_eq!(console.get_theme().success.rgb(), light_theme.success.rgb());
557 }
558
559 #[test]
560 fn test_builder_methods() {
561 let console = SqlModelConsole::new().plain_width(120);
562 assert_eq!(console.get_plain_width(), 120);
563 }
564
565 #[test]
566 fn test_set_mode() {
567 let mut console = SqlModelConsole::new();
568 console.set_mode(OutputMode::Json);
569 assert!(console.is_json());
570 }
571
572 #[test]
573 fn test_default() {
574 let console1 = SqlModelConsole::default();
575 let console2 = SqlModelConsole::new();
576 assert_eq!(console1.mode(), console2.mode());
577 }
578
579 #[test]
580 fn test_json_output() {
581 use serde::Serialize;
582
583 #[derive(Serialize)]
584 struct TestData {
585 name: String,
586 value: i32,
587 }
588
589 let console = SqlModelConsole::with_mode(OutputMode::Json);
590 let data = TestData {
591 name: "test".to_string(),
592 value: 42,
593 };
594
595 // Just verify it doesn't panic - actual output goes to stdout
596 let result = console.print_json(&data);
597 assert!(result.is_ok());
598 }
599
600 #[test]
601 fn test_json_pretty_output() {
602 use serde::Serialize;
603
604 #[derive(Serialize)]
605 struct TestData {
606 items: Vec<i32>,
607 }
608
609 let console = SqlModelConsole::with_mode(OutputMode::Plain);
610 let data = TestData {
611 items: vec![1, 2, 3],
612 };
613
614 let result = console.print_json_pretty(&data);
615 assert!(result.is_ok());
616 }
617}