Skip to main content

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}