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                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}