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}