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 // Check if this looks like markup:
410 // 1. Closing tags: starts with '/' (e.g., "/", "/bold")
411 // 2. Compound styles: contains a space (e.g., "red on white")
412 // 3. Style names: has 2+ alphabetic chars (e.g., "bold", "red")
413 let letter_count = tag_content.chars().filter(|c| c.is_alphabetic()).count();
414 let is_markup =
415 tag_content.starts_with('/') || tag_content.contains(' ') || letter_count >= 2;
416
417 if is_markup {
418 // Skip the entire tag
419 i = close_idx + 1;
420 continue;
421 }
422 }
423
424 // Not a markup tag, keep the bracket
425 result.push(c);
426 } else {
427 result.push(c);
428 }
429
430 i += 1;
431 }
432
433 result
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn test_strip_markup_basic() {
442 assert_eq!(strip_markup("[bold]text[/]"), "text");
443 assert_eq!(strip_markup("[red]hello[/]"), "hello");
444 }
445
446 #[test]
447 fn test_strip_markup_with_style() {
448 assert_eq!(strip_markup("[red on white]hello[/]"), "hello");
449 assert_eq!(strip_markup("[bold italic]styled[/]"), "styled");
450 }
451
452 #[test]
453 fn test_strip_markup_no_markup() {
454 assert_eq!(strip_markup("no markup"), "no markup");
455 assert_eq!(strip_markup("plain text"), "plain text");
456 }
457
458 #[test]
459 fn test_strip_markup_nested() {
460 assert_eq!(strip_markup("[bold][italic]nested[/][/]"), "nested");
461 // Realistic nested tags use style names, not single letters
462 assert_eq!(strip_markup("[red][bold][dim]deep[/][/][/]"), "deep");
463 }
464
465 #[test]
466 fn test_strip_markup_multiple() {
467 assert_eq!(
468 strip_markup("[bold]hello[/] [italic]world[/]"),
469 "hello world"
470 );
471 }
472
473 #[test]
474 fn test_strip_markup_preserves_brackets() {
475 // Unclosed brackets should be preserved
476 assert_eq!(strip_markup("array[0]"), "array[0]");
477 assert_eq!(strip_markup("func(a[i])"), "func(a[i])");
478 }
479
480 #[test]
481 fn test_strip_markup_empty() {
482 assert_eq!(strip_markup(""), "");
483 assert_eq!(strip_markup("[bold][/]"), "");
484 }
485
486 #[test]
487 fn test_console_creation() {
488 let console = SqlModelConsole::new();
489 // Mode depends on environment, so just check it's valid
490 assert!(matches!(
491 console.mode(),
492 OutputMode::Plain | OutputMode::Rich | OutputMode::Json
493 ));
494 }
495
496 #[test]
497 fn test_with_mode() {
498 let console = SqlModelConsole::with_mode(OutputMode::Plain);
499 assert!(console.is_plain());
500 assert!(!console.is_rich());
501 assert!(!console.is_json());
502
503 let console = SqlModelConsole::with_mode(OutputMode::Rich);
504 assert!(console.is_rich());
505 assert!(!console.is_plain());
506
507 let console = SqlModelConsole::with_mode(OutputMode::Json);
508 assert!(console.is_json());
509 }
510
511 #[test]
512 fn test_with_theme() {
513 let light_theme = Theme::light();
514 let console = SqlModelConsole::with_theme(light_theme.clone());
515 assert_eq!(console.get_theme().success.rgb(), light_theme.success.rgb());
516 }
517
518 #[test]
519 fn test_builder_methods() {
520 let console = SqlModelConsole::new().plain_width(120);
521 assert_eq!(console.get_plain_width(), 120);
522 }
523
524 #[test]
525 fn test_set_mode() {
526 let mut console = SqlModelConsole::new();
527 console.set_mode(OutputMode::Json);
528 assert!(console.is_json());
529 }
530
531 #[test]
532 fn test_default() {
533 let console1 = SqlModelConsole::default();
534 let console2 = SqlModelConsole::new();
535 assert_eq!(console1.mode(), console2.mode());
536 }
537
538 #[test]
539 fn test_json_output() {
540 use serde::Serialize;
541
542 #[derive(Serialize)]
543 struct TestData {
544 name: String,
545 value: i32,
546 }
547
548 let console = SqlModelConsole::with_mode(OutputMode::Json);
549 let data = TestData {
550 name: "test".to_string(),
551 value: 42,
552 };
553
554 // Just verify it doesn't panic - actual output goes to stdout
555 let result = console.print_json(&data);
556 assert!(result.is_ok());
557 }
558
559 #[test]
560 fn test_json_pretty_output() {
561 use serde::Serialize;
562
563 #[derive(Serialize)]
564 struct TestData {
565 items: Vec<i32>,
566 }
567
568 let console = SqlModelConsole::with_mode(OutputMode::Plain);
569 let data = TestData {
570 items: vec![1, 2, 3],
571 };
572
573 let result = console.print_json_pretty(&data);
574 assert!(result.is_ok());
575 }
576}