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