Skip to main content

sqlmodel_console/
mode.rs

1//! Output mode detection for agent-safe console output.
2//!
3//! This module provides automatic detection of whether output should be
4//! plain text (for AI agents and CI) or richly formatted (for humans).
5//!
6//! # Detection Priority
7//!
8//! The detection follows this priority order (first match wins):
9//!
10//! 1. `SQLMODEL_PLAIN=1` - Force plain output
11//! 2. `SQLMODEL_JSON=1` - Force JSON output
12//! 3. `SQLMODEL_RICH=1` - Force rich output (overrides agent detection!)
13//! 4. `NO_COLOR` - Standard env var for disabling colors
14//! 5. `CI=true` - CI environment detection
15//! 6. `TERM=dumb` - Dumb terminal
16//! 7. Agent env vars - Claude Code, Codex CLI, Cursor, etc.
17//! 8. `!is_terminal(stdout)` - Piped or redirected output
18//! 9. Default: Rich output
19//!
20//! # Agent Detection
21//!
22//! The following AI coding agents are detected:
23//!
24//! - Claude Code (`CLAUDE_CODE`)
25//! - OpenAI Codex CLI (`CODEX_CLI`)
26//! - Cursor IDE (`CURSOR_SESSION`)
27//! - Aider (`AIDER_MODEL`, `AIDER_REPO`)
28//! - GitHub Copilot (`GITHUB_COPILOT`)
29//! - Continue.dev (`CONTINUE_SESSION`)
30//! - Generic agent marker (`AGENT_MODE`)
31
32use std::env;
33use std::io::IsTerminal;
34
35/// Output mode for console rendering.
36///
37/// Determines how console output should be formatted. The mode is automatically
38/// detected based on environment variables and terminal state, but can be
39/// overridden via `SQLMODEL_PLAIN`, `SQLMODEL_RICH`, or `SQLMODEL_JSON`.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
41pub enum OutputMode {
42    /// Plain text output, no ANSI codes. Machine-parseable.
43    ///
44    /// Used for: AI agents, CI systems, piped output, dumb terminals.
45    Plain,
46
47    /// Rich formatted output with colors, tables, panels.
48    ///
49    /// Used for: Interactive human terminal sessions.
50    #[default]
51    Rich,
52
53    /// Structured JSON output for programmatic consumption.
54    ///
55    /// Used for: Tool integrations, scripting, IDEs.
56    Json,
57}
58
59impl OutputMode {
60    /// Detect the appropriate output mode from the environment.
61    ///
62    /// This function checks various environment variables and terminal state
63    /// to determine the best output mode. The detection is deterministic and
64    /// follows a well-defined priority order.
65    ///
66    /// # Priority Order
67    ///
68    /// 1. `SQLMODEL_PLAIN=1` - Force plain output
69    /// 2. `SQLMODEL_JSON=1` - Force JSON output
70    /// 3. `SQLMODEL_RICH=1` - Force rich output (overrides agent detection!)
71    /// 4. `NO_COLOR` present - Plain (standard convention)
72    /// 5. `CI=true` - Plain (CI environment)
73    /// 6. `TERM=dumb` - Plain (dumb terminal)
74    /// 7. Agent environment detected - Plain
75    /// 8. stdout is not a TTY - Plain
76    /// 9. Default - Rich
77    ///
78    /// # Examples
79    ///
80    /// ```rust
81    /// use sqlmodel_console::OutputMode;
82    ///
83    /// let mode = OutputMode::detect();
84    /// match mode {
85    ///     OutputMode::Plain => println!("Using plain text"),
86    ///     OutputMode::Rich => println!("Using rich formatting"),
87    ///     OutputMode::Json => println!("Using JSON output"),
88    /// }
89    /// ```
90    #[must_use]
91    pub fn detect() -> Self {
92        // Explicit overrides (highest priority)
93        if env_is_truthy("SQLMODEL_PLAIN") {
94            return Self::Plain;
95        }
96        if env_is_truthy("SQLMODEL_JSON") {
97            return Self::Json;
98        }
99        if env_is_truthy("SQLMODEL_RICH") {
100            return Self::Rich; // Force rich even for agents
101        }
102
103        // Standard "no color" convention (https://no-color.org/)
104        if env::var("NO_COLOR").is_ok() {
105            return Self::Plain;
106        }
107
108        // CI environments
109        if env_is_truthy("CI") {
110            return Self::Plain;
111        }
112
113        // Dumb terminal
114        if env::var("TERM").is_ok_and(|t| t == "dumb") {
115            return Self::Plain;
116        }
117
118        // Agent detection
119        if Self::is_agent_environment() {
120            return Self::Plain;
121        }
122
123        // Not a TTY (piped, redirected)
124        if !std::io::stdout().is_terminal() {
125            return Self::Plain;
126        }
127
128        // Default: rich output for humans
129        Self::Rich
130    }
131
132    /// Check if we're running in an AI coding agent environment.
133    ///
134    /// This function checks for environment variables set by known AI coding
135    /// assistants. When detected, we default to plain output to ensure
136    /// machine-parseability.
137    ///
138    /// # Known Agent Environment Variables
139    ///
140    /// - `CLAUDE_CODE` - Claude Code CLI
141    /// - `CODEX_CLI` - OpenAI Codex CLI
142    /// - `CURSOR_SESSION` - Cursor IDE
143    /// - `AIDER_MODEL` / `AIDER_REPO` - Aider coding assistant
144    /// - `AGENT_MODE` - Generic agent marker
145    /// - `GITHUB_COPILOT` - GitHub Copilot
146    /// - `CONTINUE_SESSION` - Continue.dev extension
147    /// - `CODY_*` - Sourcegraph Cody
148    /// - `WINDSURF_*` - Windsurf/Codeium
149    /// - `GEMINI_CLI` - Google Gemini CLI
150    ///
151    /// # Returns
152    ///
153    /// `true` if any agent environment variable is detected.
154    ///
155    /// # Examples
156    ///
157    /// ```rust
158    /// use sqlmodel_console::OutputMode;
159    ///
160    /// if OutputMode::is_agent_environment() {
161    ///     println!("Running under an AI agent");
162    /// }
163    /// ```
164    #[must_use]
165    pub fn is_agent_environment() -> bool {
166        const AGENT_MARKERS: &[&str] = &[
167            // Claude/Anthropic
168            "CLAUDE_CODE",
169            // OpenAI
170            "CODEX_CLI",
171            "CODEX_SESSION",
172            // Cursor
173            "CURSOR_SESSION",
174            "CURSOR_EDITOR",
175            // Aider
176            "AIDER_MODEL",
177            "AIDER_REPO",
178            // Generic
179            "AGENT_MODE",
180            "AI_AGENT",
181            // GitHub Copilot
182            "GITHUB_COPILOT",
183            "COPILOT_SESSION",
184            // Continue.dev
185            "CONTINUE_SESSION",
186            // Sourcegraph Cody
187            "CODY_AGENT",
188            "CODY_SESSION",
189            // Windsurf/Codeium
190            "WINDSURF_SESSION",
191            "CODEIUM_AGENT",
192            // Google Gemini
193            "GEMINI_CLI",
194            "GEMINI_SESSION",
195            // Amazon CodeWhisperer / Q
196            "CODEWHISPERER_SESSION",
197            "AMAZON_Q_SESSION",
198        ];
199
200        AGENT_MARKERS.iter().any(|var| env::var(var).is_ok())
201    }
202
203    /// Check if this mode should use ANSI escape codes.
204    ///
205    /// Returns `true` only for `Rich` mode, which is the only mode that
206    /// uses colors and formatting.
207    ///
208    /// # Examples
209    ///
210    /// ```rust
211    /// use sqlmodel_console::OutputMode;
212    ///
213    /// assert!(!OutputMode::Plain.supports_ansi());
214    /// assert!(OutputMode::Rich.supports_ansi());
215    /// assert!(!OutputMode::Json.supports_ansi());
216    /// ```
217    #[must_use]
218    pub const fn supports_ansi(&self) -> bool {
219        matches!(self, Self::Rich)
220    }
221
222    /// Check if this mode uses structured format.
223    ///
224    /// Returns `true` only for `Json` mode, which outputs structured data
225    /// for programmatic consumption.
226    ///
227    /// # Examples
228    ///
229    /// ```rust
230    /// use sqlmodel_console::OutputMode;
231    ///
232    /// assert!(!OutputMode::Plain.is_structured());
233    /// assert!(!OutputMode::Rich.is_structured());
234    /// assert!(OutputMode::Json.is_structured());
235    /// ```
236    #[must_use]
237    pub const fn is_structured(&self) -> bool {
238        matches!(self, Self::Json)
239    }
240
241    /// Check if this mode is plain text.
242    ///
243    /// # Examples
244    ///
245    /// ```rust
246    /// use sqlmodel_console::OutputMode;
247    ///
248    /// assert!(OutputMode::Plain.is_plain());
249    /// assert!(!OutputMode::Rich.is_plain());
250    /// assert!(!OutputMode::Json.is_plain());
251    /// ```
252    #[must_use]
253    pub const fn is_plain(&self) -> bool {
254        matches!(self, Self::Plain)
255    }
256
257    /// Check if this mode uses rich formatting.
258    ///
259    /// # Examples
260    ///
261    /// ```rust
262    /// use sqlmodel_console::OutputMode;
263    ///
264    /// assert!(!OutputMode::Plain.is_rich());
265    /// assert!(OutputMode::Rich.is_rich());
266    /// assert!(!OutputMode::Json.is_rich());
267    /// ```
268    #[must_use]
269    pub const fn is_rich(&self) -> bool {
270        matches!(self, Self::Rich)
271    }
272
273    /// Get the mode name as a string slice.
274    ///
275    /// # Examples
276    ///
277    /// ```rust
278    /// use sqlmodel_console::OutputMode;
279    ///
280    /// assert_eq!(OutputMode::Plain.as_str(), "plain");
281    /// assert_eq!(OutputMode::Rich.as_str(), "rich");
282    /// assert_eq!(OutputMode::Json.as_str(), "json");
283    /// ```
284    #[must_use]
285    pub const fn as_str(&self) -> &'static str {
286        match self {
287            Self::Plain => "plain",
288            Self::Rich => "rich",
289            Self::Json => "json",
290        }
291    }
292}
293
294impl std::fmt::Display for OutputMode {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        f.write_str(self.as_str())
297    }
298}
299
300/// Check if an environment variable is set to a truthy value.
301///
302/// Recognizes: `1`, `true`, `yes`, `on` (case-insensitive).
303fn env_is_truthy(name: &str) -> bool {
304    env::var(name).is_ok_and(|v| {
305        let v = v.to_lowercase();
306        v == "1" || v == "true" || v == "yes" || v == "on"
307    })
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use std::env;
314
315    /// Environment variables to clean before each test.
316    const VARS_TO_CLEAR: &[&str] = &[
317        "SQLMODEL_PLAIN",
318        "SQLMODEL_JSON",
319        "SQLMODEL_RICH",
320        "NO_COLOR",
321        "CI",
322        "TERM",
323        "CLAUDE_CODE",
324        "CODEX_CLI",
325        "CURSOR_SESSION",
326        "AIDER_MODEL",
327        "AGENT_MODE",
328        "GITHUB_COPILOT",
329        "CONTINUE_SESSION",
330    ];
331
332    /// Wrapper for env::set_var (unsafe in Rust 2024 edition).
333    ///
334    /// # Safety
335    /// This is only safe in single-threaded test contexts with #[test].
336    /// Tests must be run with `--test-threads=1` for safety.
337    #[allow(unsafe_code)]
338    fn test_set_var(key: &str, value: &str) {
339        // SAFETY: Tests are run single-threaded via `cargo test -- --test-threads=1`
340        // or the env manipulation is isolated to a single test function.
341        unsafe { env::set_var(key, value) };
342    }
343
344    /// Wrapper for env::remove_var (unsafe in Rust 2024 edition).
345    #[allow(unsafe_code)]
346    fn test_remove_var(key: &str) {
347        // SAFETY: Same as test_set_var
348        unsafe { env::remove_var(key) };
349    }
350
351    /// Helper to run test with clean environment.
352    fn with_clean_env<F: FnOnce()>(f: F) {
353        // Save current values
354        let saved: Vec<_> = VARS_TO_CLEAR
355            .iter()
356            .map(|&v| (v, env::var(v).ok()))
357            .collect();
358
359        // Clear all relevant vars
360        for &var in VARS_TO_CLEAR {
361            test_remove_var(var);
362        }
363
364        // Run the test
365        f();
366
367        // Restore original values
368        for (var, val) in saved {
369            match val {
370                Some(v) => test_set_var(var, &v),
371                None => test_remove_var(var),
372            }
373        }
374    }
375
376    #[test]
377    fn test_default_is_rich() {
378        assert_eq!(OutputMode::default(), OutputMode::Rich);
379    }
380
381    #[test]
382    fn test_explicit_plain_override() {
383        with_clean_env(|| {
384            test_set_var("SQLMODEL_PLAIN", "1");
385            assert_eq!(OutputMode::detect(), OutputMode::Plain);
386        });
387    }
388
389    #[test]
390    fn test_explicit_plain_override_true() {
391        with_clean_env(|| {
392            test_set_var("SQLMODEL_PLAIN", "true");
393            assert_eq!(OutputMode::detect(), OutputMode::Plain);
394        });
395    }
396
397    #[test]
398    #[ignore = "flaky: env var race conditions in parallel tests"]
399    fn test_explicit_json_override() {
400        with_clean_env(|| {
401            test_set_var("SQLMODEL_JSON", "1");
402            assert_eq!(OutputMode::detect(), OutputMode::Json);
403        });
404    }
405
406    #[test]
407    #[ignore = "flaky: env var race conditions in parallel tests (CI sets CI=true)"]
408    fn test_explicit_rich_override() {
409        with_clean_env(|| {
410            test_set_var("SQLMODEL_RICH", "1");
411            // Note: This test runs in a non-TTY context (cargo test),
412            // but SQLMODEL_RICH should still force rich mode
413            assert_eq!(OutputMode::detect(), OutputMode::Rich);
414        });
415    }
416
417    #[test]
418    #[ignore = "flaky: env var race conditions in parallel tests"]
419    fn test_plain_takes_priority_over_json() {
420        with_clean_env(|| {
421            test_set_var("SQLMODEL_PLAIN", "1");
422            test_set_var("SQLMODEL_JSON", "1");
423            assert_eq!(OutputMode::detect(), OutputMode::Plain);
424        });
425    }
426
427    #[test]
428    #[ignore = "flaky: env var race conditions in parallel tests"]
429    fn test_agent_detection_claude() {
430        with_clean_env(|| {
431            test_set_var("CLAUDE_CODE", "1");
432            assert!(OutputMode::is_agent_environment());
433        });
434    }
435
436    #[test]
437    #[ignore = "flaky: env var race conditions in parallel tests"]
438    fn test_agent_detection_codex() {
439        with_clean_env(|| {
440            test_set_var("CODEX_CLI", "1");
441            assert!(OutputMode::is_agent_environment());
442        });
443    }
444
445    #[test]
446    #[ignore = "flaky: env var race conditions in parallel tests"]
447    fn test_agent_detection_cursor() {
448        with_clean_env(|| {
449            test_set_var("CURSOR_SESSION", "active");
450            assert!(OutputMode::is_agent_environment());
451        });
452    }
453
454    #[test]
455    #[ignore = "flaky: env var race conditions in parallel tests"]
456    fn test_agent_detection_aider() {
457        with_clean_env(|| {
458            test_set_var("AIDER_MODEL", "gpt-4");
459            assert!(OutputMode::is_agent_environment());
460        });
461    }
462
463    #[test]
464    #[ignore = "flaky: env var race conditions in parallel tests"]
465    fn test_agent_causes_plain_mode() {
466        with_clean_env(|| {
467            test_set_var("CLAUDE_CODE", "1");
468            assert_eq!(OutputMode::detect(), OutputMode::Plain);
469        });
470    }
471
472    #[test]
473    #[ignore = "flaky: env var race conditions in parallel tests (CI sets CI=true)"]
474    fn test_rich_override_beats_agent() {
475        with_clean_env(|| {
476            test_set_var("CLAUDE_CODE", "1");
477            test_set_var("SQLMODEL_RICH", "1");
478            assert_eq!(OutputMode::detect(), OutputMode::Rich);
479        });
480    }
481
482    #[test]
483    #[ignore = "flaky: env var race conditions in parallel tests"]
484    fn test_no_color_causes_plain() {
485        with_clean_env(|| {
486            test_set_var("NO_COLOR", "");
487            assert_eq!(OutputMode::detect(), OutputMode::Plain);
488        });
489    }
490
491    #[test]
492    #[ignore = "flaky: env var race conditions in parallel tests"]
493    fn test_ci_causes_plain() {
494        with_clean_env(|| {
495            test_set_var("CI", "true");
496            assert_eq!(OutputMode::detect(), OutputMode::Plain);
497        });
498    }
499
500    #[test]
501    #[ignore = "flaky: env var race conditions in parallel tests"]
502    fn test_dumb_terminal_causes_plain() {
503        with_clean_env(|| {
504            test_set_var("TERM", "dumb");
505            assert_eq!(OutputMode::detect(), OutputMode::Plain);
506        });
507    }
508
509    #[test]
510    fn test_supports_ansi() {
511        assert!(!OutputMode::Plain.supports_ansi());
512        assert!(OutputMode::Rich.supports_ansi());
513        assert!(!OutputMode::Json.supports_ansi());
514    }
515
516    #[test]
517    fn test_is_structured() {
518        assert!(!OutputMode::Plain.is_structured());
519        assert!(!OutputMode::Rich.is_structured());
520        assert!(OutputMode::Json.is_structured());
521    }
522
523    #[test]
524    fn test_is_plain() {
525        assert!(OutputMode::Plain.is_plain());
526        assert!(!OutputMode::Rich.is_plain());
527        assert!(!OutputMode::Json.is_plain());
528    }
529
530    #[test]
531    fn test_is_rich() {
532        assert!(!OutputMode::Plain.is_rich());
533        assert!(OutputMode::Rich.is_rich());
534        assert!(!OutputMode::Json.is_rich());
535    }
536
537    #[test]
538    fn test_as_str() {
539        assert_eq!(OutputMode::Plain.as_str(), "plain");
540        assert_eq!(OutputMode::Rich.as_str(), "rich");
541        assert_eq!(OutputMode::Json.as_str(), "json");
542    }
543
544    #[test]
545    fn test_display() {
546        assert_eq!(format!("{}", OutputMode::Plain), "plain");
547        assert_eq!(format!("{}", OutputMode::Rich), "rich");
548        assert_eq!(format!("{}", OutputMode::Json), "json");
549    }
550
551    #[test]
552    fn test_env_is_truthy() {
553        with_clean_env(|| {
554            // Not set
555            assert!(!env_is_truthy("SQLMODEL_TEST_VAR"));
556
557            // Various truthy values
558            test_set_var("SQLMODEL_TEST_VAR", "1");
559            assert!(env_is_truthy("SQLMODEL_TEST_VAR"));
560
561            test_set_var("SQLMODEL_TEST_VAR", "true");
562            assert!(env_is_truthy("SQLMODEL_TEST_VAR"));
563
564            test_set_var("SQLMODEL_TEST_VAR", "TRUE");
565            assert!(env_is_truthy("SQLMODEL_TEST_VAR"));
566
567            test_set_var("SQLMODEL_TEST_VAR", "yes");
568            assert!(env_is_truthy("SQLMODEL_TEST_VAR"));
569
570            test_set_var("SQLMODEL_TEST_VAR", "on");
571            assert!(env_is_truthy("SQLMODEL_TEST_VAR"));
572
573            // Falsy values
574            test_set_var("SQLMODEL_TEST_VAR", "0");
575            assert!(!env_is_truthy("SQLMODEL_TEST_VAR"));
576
577            test_set_var("SQLMODEL_TEST_VAR", "false");
578            assert!(!env_is_truthy("SQLMODEL_TEST_VAR"));
579
580            test_set_var("SQLMODEL_TEST_VAR", "");
581            assert!(!env_is_truthy("SQLMODEL_TEST_VAR"));
582
583            test_remove_var("SQLMODEL_TEST_VAR");
584        });
585    }
586
587    #[test]
588    #[ignore = "flaky: env var race conditions in parallel tests"]
589    fn test_no_agent_when_clean() {
590        with_clean_env(|| {
591            assert!(!OutputMode::is_agent_environment());
592        });
593    }
594}