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