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        Self::detect_with_env(|var| env::var(var).ok(), std::io::stdout().is_terminal())
93    }
94
95    /// Detect the appropriate output mode using an environment variable reader and terminal indicator.
96    ///
97    /// This allows caller-injected environments (e.g. mock environments in tests)
98    /// without mutating global process state.
99    #[must_use]
100    pub fn detect_with_env<F>(env_lookup: F, is_terminal: bool) -> Self
101    where
102        F: Fn(&str) -> Option<String>,
103    {
104        let is_truthy = |var: &str| -> bool {
105            env_lookup(var).is_some_and(|val| {
106                let v = val.trim().to_lowercase();
107                v == "1" || v == "true" || v == "yes" || v == "on"
108            })
109        };
110
111        // Explicit overrides (highest priority)
112        if is_truthy("SQLMODEL_PLAIN") {
113            return Self::Plain;
114        }
115        if is_truthy("SQLMODEL_JSON") {
116            return Self::Json;
117        }
118        if is_truthy("SQLMODEL_RICH") {
119            return Self::Rich; // Force rich even for agents
120        }
121
122        // Standard "no color" convention (https://no-color.org/)
123        if env_lookup("NO_COLOR").is_some() {
124            return Self::Plain;
125        }
126
127        // CI environments
128        if is_truthy("CI") {
129            return Self::Plain;
130        }
131
132        // Dumb terminal
133        if env_lookup("TERM").is_some_and(|t| t == "dumb") {
134            return Self::Plain;
135        }
136
137        // Agent detection
138        if Self::is_agent_environment_with(&env_lookup) {
139            return Self::Plain;
140        }
141
142        // Not a TTY (piped, redirected)
143        if !is_terminal {
144            return Self::Plain;
145        }
146
147        // Default: rich output for humans
148        Self::Rich
149    }
150
151    /// Check if we're running in an AI coding agent environment.
152    ///
153    /// This function checks for environment variables set by known AI coding
154    /// assistants. When detected, we default to plain output to ensure
155    /// machine-parseability.
156    ///
157    /// # Known Agent Environment Variables
158    ///
159    /// - `CLAUDE_CODE` - Claude Code CLI
160    /// - `CODEX_CLI` - OpenAI Codex CLI
161    /// - `CURSOR_SESSION` - Cursor IDE
162    /// - `AIDER_MODEL` / `AIDER_REPO` - Aider coding assistant
163    /// - `AGENT_MODE` - Generic agent marker
164    /// - `GITHUB_COPILOT` - GitHub Copilot
165    /// - `CONTINUE_SESSION` - Continue.dev extension
166    /// - `CODY_*` - Sourcegraph Cody
167    /// - `WINDSURF_*` - Windsurf/Codeium
168    /// - `GEMINI_CLI` - Google Gemini CLI
169    ///
170    /// # Returns
171    ///
172    /// `true` if any agent environment variable is detected.
173    ///
174    /// # Examples
175    ///
176    /// ```rust
177    /// use sqlmodel_console::OutputMode;
178    ///
179    /// if OutputMode::is_agent_environment() {
180    ///     println!("Running under an AI agent");
181    /// }
182    /// ```
183    #[must_use]
184    pub fn is_agent_environment() -> bool {
185        Self::is_agent_environment_with(|var| env::var(var).ok())
186    }
187
188    /// Check if we're running in an AI coding agent environment using a custom environment lookup.
189    #[must_use]
190    pub fn is_agent_environment_with<F>(env_lookup: F) -> bool
191    where
192        F: Fn(&str) -> Option<String>,
193    {
194        const AGENT_MARKERS: &[&str] = &[
195            // Claude/Anthropic
196            "CLAUDE_CODE",
197            // OpenAI
198            "CODEX_CLI",
199            "CODEX_SESSION",
200            // Cursor
201            "CURSOR_SESSION",
202            "CURSOR_EDITOR",
203            // Aider
204            "AIDER_MODEL",
205            "AIDER_REPO",
206            // Generic
207            "AGENT_MODE",
208            "AI_AGENT",
209            // GitHub Copilot
210            "GITHUB_COPILOT",
211            "COPILOT_SESSION",
212            // Continue.dev
213            "CONTINUE_SESSION",
214            // Sourcegraph Cody
215            "CODY_AGENT",
216            "CODY_SESSION",
217            // Windsurf/Codeium
218            "WINDSURF_SESSION",
219            "CODEIUM_AGENT",
220            // Google Gemini
221            "GEMINI_CLI",
222            "GEMINI_SESSION",
223            // Amazon CodeWhisperer / Q
224            "CODEWHISPERER_SESSION",
225            "AMAZON_Q_SESSION",
226        ];
227
228        AGENT_MARKERS.iter().any(|var| env_lookup(var).is_some())
229    }
230
231    /// Check if this mode should use ANSI escape codes.
232    ///
233    /// Returns `true` only for `Rich` mode, which is the only mode that
234    /// uses colors and formatting.
235    ///
236    /// # Examples
237    ///
238    /// ```rust
239    /// use sqlmodel_console::OutputMode;
240    ///
241    /// assert!(!OutputMode::Plain.supports_ansi());
242    /// assert!(OutputMode::Rich.supports_ansi());
243    /// assert!(!OutputMode::Json.supports_ansi());
244    /// ```
245    #[must_use]
246    pub const fn supports_ansi(&self) -> bool {
247        matches!(self, Self::Rich)
248    }
249
250    /// Check if this mode uses structured format.
251    ///
252    /// Returns `true` only for `Json` mode, which outputs structured data
253    /// for programmatic consumption.
254    ///
255    /// # Examples
256    ///
257    /// ```rust
258    /// use sqlmodel_console::OutputMode;
259    ///
260    /// assert!(!OutputMode::Plain.is_structured());
261    /// assert!(!OutputMode::Rich.is_structured());
262    /// assert!(OutputMode::Json.is_structured());
263    /// ```
264    #[must_use]
265    pub const fn is_structured(&self) -> bool {
266        matches!(self, Self::Json)
267    }
268
269    /// Check if this mode is plain text.
270    ///
271    /// # Examples
272    ///
273    /// ```rust
274    /// use sqlmodel_console::OutputMode;
275    ///
276    /// assert!(OutputMode::Plain.is_plain());
277    /// assert!(!OutputMode::Rich.is_plain());
278    /// assert!(!OutputMode::Json.is_plain());
279    /// ```
280    #[must_use]
281    pub const fn is_plain(&self) -> bool {
282        matches!(self, Self::Plain)
283    }
284
285    /// Check if this mode uses rich formatting.
286    ///
287    /// # Examples
288    ///
289    /// ```rust
290    /// use sqlmodel_console::OutputMode;
291    ///
292    /// assert!(!OutputMode::Plain.is_rich());
293    /// assert!(OutputMode::Rich.is_rich());
294    /// assert!(!OutputMode::Json.is_rich());
295    /// ```
296    #[must_use]
297    pub const fn is_rich(&self) -> bool {
298        matches!(self, Self::Rich)
299    }
300
301    /// Get the mode name as a string slice.
302    ///
303    /// # Examples
304    ///
305    /// ```rust
306    /// use sqlmodel_console::OutputMode;
307    ///
308    /// assert_eq!(OutputMode::Plain.as_str(), "plain");
309    /// assert_eq!(OutputMode::Rich.as_str(), "rich");
310    /// assert_eq!(OutputMode::Json.as_str(), "json");
311    /// ```
312    #[must_use]
313    pub const fn as_str(&self) -> &'static str {
314        match self {
315            Self::Plain => "plain",
316            Self::Rich => "rich",
317            Self::Json => "json",
318        }
319    }
320}
321
322impl std::fmt::Display for OutputMode {
323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        f.write_str(self.as_str())
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn mock_env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
333        let owned: Vec<(String, String)> = vars
334            .iter()
335            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
336            .collect();
337        move |key| owned.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
338    }
339
340    #[test]
341    fn test_default_is_rich() {
342        assert_eq!(OutputMode::default(), OutputMode::Rich);
343    }
344
345    #[test]
346    fn test_explicit_plain_override() {
347        assert_eq!(
348            OutputMode::detect_with_env(mock_env(&[("SQLMODEL_PLAIN", "1")]), true),
349            OutputMode::Plain
350        );
351    }
352
353    #[test]
354    fn test_explicit_plain_override_true() {
355        assert_eq!(
356            OutputMode::detect_with_env(mock_env(&[("SQLMODEL_PLAIN", "true")]), true),
357            OutputMode::Plain
358        );
359    }
360
361    #[test]
362    fn test_explicit_json_override() {
363        assert_eq!(
364            OutputMode::detect_with_env(mock_env(&[("SQLMODEL_JSON", "1")]), true),
365            OutputMode::Json
366        );
367    }
368
369    #[test]
370    fn test_explicit_rich_override() {
371        // Note: Even when terminal is false, SQLMODEL_RICH forces rich mode
372        assert_eq!(
373            OutputMode::detect_with_env(mock_env(&[("SQLMODEL_RICH", "1")]), false),
374            OutputMode::Rich
375        );
376    }
377
378    #[test]
379    fn test_plain_takes_priority_over_json() {
380        assert_eq!(
381            OutputMode::detect_with_env(
382                mock_env(&[("SQLMODEL_PLAIN", "1"), ("SQLMODEL_JSON", "1")]),
383                true
384            ),
385            OutputMode::Plain
386        );
387    }
388
389    #[test]
390    fn test_agent_detection_claude() {
391        assert!(OutputMode::is_agent_environment_with(mock_env(&[(
392            "CLAUDE_CODE",
393            "1"
394        )])));
395    }
396
397    #[test]
398    fn test_agent_detection_codex() {
399        assert!(OutputMode::is_agent_environment_with(mock_env(&[(
400            "CODEX_CLI",
401            "1"
402        )])));
403    }
404
405    #[test]
406    fn test_agent_detection_cursor() {
407        assert!(OutputMode::is_agent_environment_with(mock_env(&[(
408            "CURSOR_SESSION",
409            "active"
410        )])));
411    }
412
413    #[test]
414    fn test_agent_detection_aider() {
415        assert!(OutputMode::is_agent_environment_with(mock_env(&[(
416            "AIDER_MODEL",
417            "gpt-4"
418        )])));
419    }
420
421    #[test]
422    fn test_agent_causes_plain_mode() {
423        assert_eq!(
424            OutputMode::detect_with_env(mock_env(&[("CLAUDE_CODE", "1")]), true),
425            OutputMode::Plain
426        );
427    }
428
429    #[test]
430    fn test_rich_override_beats_agent() {
431        assert_eq!(
432            OutputMode::detect_with_env(
433                mock_env(&[("CLAUDE_CODE", "1"), ("SQLMODEL_RICH", "1")]),
434                true
435            ),
436            OutputMode::Rich
437        );
438    }
439
440    #[test]
441    fn test_no_color_causes_plain() {
442        assert_eq!(
443            OutputMode::detect_with_env(mock_env(&[("NO_COLOR", "")]), true),
444            OutputMode::Plain
445        );
446    }
447
448    #[test]
449    fn test_ci_causes_plain() {
450        assert_eq!(
451            OutputMode::detect_with_env(mock_env(&[("CI", "true")]), true),
452            OutputMode::Plain
453        );
454    }
455
456    #[test]
457    fn test_dumb_terminal_causes_plain() {
458        assert_eq!(
459            OutputMode::detect_with_env(mock_env(&[("TERM", "dumb")]), true),
460            OutputMode::Plain
461        );
462    }
463
464    #[test]
465    fn test_supports_ansi() {
466        assert!(!OutputMode::Plain.supports_ansi());
467        assert!(OutputMode::Rich.supports_ansi());
468        assert!(!OutputMode::Json.supports_ansi());
469    }
470
471    #[test]
472    fn test_is_structured() {
473        assert!(!OutputMode::Plain.is_structured());
474        assert!(!OutputMode::Rich.is_structured());
475        assert!(OutputMode::Json.is_structured());
476    }
477
478    #[test]
479    fn test_is_plain() {
480        assert!(OutputMode::Plain.is_plain());
481        assert!(!OutputMode::Rich.is_plain());
482        assert!(!OutputMode::Json.is_plain());
483    }
484
485    #[test]
486    fn test_is_rich() {
487        assert!(!OutputMode::Plain.is_rich());
488        assert!(OutputMode::Rich.is_rich());
489        assert!(!OutputMode::Json.is_rich());
490    }
491
492    #[test]
493    fn test_as_str() {
494        assert_eq!(OutputMode::Plain.as_str(), "plain");
495        assert_eq!(OutputMode::Rich.as_str(), "rich");
496        assert_eq!(OutputMode::Json.as_str(), "json");
497    }
498
499    #[test]
500    fn test_display() {
501        assert_eq!(format!("{}", OutputMode::Plain), "plain");
502        assert_eq!(format!("{}", OutputMode::Rich), "rich");
503        assert_eq!(format!("{}", OutputMode::Json), "json");
504    }
505
506    #[test]
507    fn test_env_is_truthy() {
508        // Empty / unset
509        let empty = mock_env(&[]);
510        assert_eq!(OutputMode::detect_with_env(&empty, true), OutputMode::Rich);
511
512        // Various truthy values
513        for truthy in ["1", "true", "TRUE", "yes", "on"] {
514            let env = mock_env(&[("SQLMODEL_PLAIN", truthy)]);
515            assert_eq!(
516                OutputMode::detect_with_env(&env, true),
517                OutputMode::Plain,
518                "truthy check failed for {truthy}"
519            );
520        }
521
522        // Falsy values
523        for falsy in ["0", "false", "no", "off", ""] {
524            let env = mock_env(&[("SQLMODEL_PLAIN", falsy)]);
525            assert_eq!(
526                OutputMode::detect_with_env(&env, true),
527                OutputMode::Rich,
528                "falsy check failed for {falsy}"
529            );
530        }
531    }
532
533    #[test]
534    fn test_no_agent_when_clean() {
535        assert!(!OutputMode::is_agent_environment_with(mock_env(&[])));
536    }
537}