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}