Skip to main content

fastapi_output/
detection.rs

1//! Agent environment detection for output mode selection.
2//!
3//! This module provides heuristics to detect whether the current process
4//! is running under an AI coding agent (Claude Code, Codex, Cursor, etc.)
5//! or in a human-interactive terminal.
6
7use crossterm::tty::IsTty;
8use std::env;
9use std::io::stdout;
10
11/// Known AI agent environment variables.
12///
13/// When any of these are set, we assume an agent is running the process.
14const AGENT_ENV_VARS: &[&str] = &[
15    "CLAUDE_CODE",      // Claude Code CLI
16    "CODEX_CLI",        // OpenAI Codex CLI
17    "CURSOR_SESSION",   // Cursor IDE
18    "AIDER_SESSION",    // Aider
19    "AGENT_MODE",       // Generic agent flag
20    "WINDSURF_SESSION", // Windsurf
21    "CLINE_SESSION",    // Cline
22    "COPILOT_AGENT",    // GitHub Copilot agent mode
23];
24
25/// CI environment variables that indicate non-interactive execution.
26const CI_ENV_VARS: &[&str] = &[
27    "CI",             // Generic CI flag
28    "GITHUB_ACTIONS", // GitHub Actions
29    "GITLAB_CI",      // GitLab CI
30    "JENKINS_URL",    // Jenkins
31    "CIRCLECI",       // CircleCI
32    "TRAVIS",         // Travis CI
33    "BUILDKITE",      // Buildkite
34];
35
36/// Detection result with diagnostics.
37#[derive(Debug, Clone)]
38#[allow(clippy::struct_excessive_bools)]
39pub struct DetectionResult {
40    /// Whether an agent environment was detected.
41    pub is_agent: bool,
42    /// The specific agent variable that was detected.
43    pub detected_agent: Option<String>,
44    /// Whether a CI environment was detected.
45    pub is_ci: bool,
46    /// The specific CI variable that was detected.
47    pub detected_ci: Option<String>,
48    /// Whether stdout is connected to a TTY.
49    pub is_tty: bool,
50    /// Whether NO_COLOR environment variable is set.
51    pub no_color_set: bool,
52    /// Any override mode that was specified.
53    pub override_mode: Option<OverrideMode>,
54}
55
56/// Override modes for forcing specific detection results.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum OverrideMode {
59    /// Force agent mode (FASTAPI_AGENT_MODE=1).
60    ForceAgent,
61    /// Force human mode (FASTAPI_HUMAN_MODE=1).
62    ForceHuman,
63}
64
65/// Output preference based on detection.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum OutputPreference {
68    /// Rich output with colors and styling.
69    Rich,
70    /// Plain text output.
71    Plain,
72}
73
74/// Check if running under an AI coding agent.
75///
76/// This is the main entry point for simple detection checks.
77///
78/// # Returns
79///
80/// `true` if agent environment is detected, `false` otherwise.
81///
82/// # Example
83///
84/// ```rust
85/// use fastapi_output::detection::is_agent_environment;
86///
87/// if is_agent_environment() {
88///     println!("Running in agent mode - using plain output");
89/// } else {
90///     println!("Running in human mode - using rich output");
91/// }
92/// ```
93#[must_use]
94pub fn is_agent_environment() -> bool {
95    detect_environment().is_agent
96}
97
98/// Full detection with diagnostics for debugging.
99///
100/// Returns a `DetectionResult` with detailed information about
101/// what was detected and why.
102#[must_use]
103pub fn detect_environment() -> DetectionResult {
104    // Check for explicit overrides first
105    let override_mode = check_overrides();
106    let force_color = force_color_enabled();
107
108    // Check agent env vars
109    let (is_agent_var, detected_agent) = check_agent_vars();
110
111    // Check CI env vars
112    let (is_ci_var, detected_ci) = check_ci_vars();
113
114    // Check NO_COLOR standard
115    let no_color_set = env::var("NO_COLOR").is_ok();
116
117    // Check if stdout is a TTY
118    let is_tty = stdout().is_tty();
119
120    // Final determination
121    let is_agent = match override_mode {
122        Some(OverrideMode::ForceAgent) => true,
123        Some(OverrideMode::ForceHuman) => false,
124        None => {
125            if force_color {
126                false
127            } else {
128                is_agent_var || is_ci_var || no_color_set || !is_tty
129            }
130        }
131    };
132
133    DetectionResult {
134        is_agent,
135        detected_agent,
136        is_ci: is_ci_var,
137        detected_ci,
138        is_tty,
139        no_color_set,
140        override_mode,
141    }
142}
143
144/// Check for override environment variables.
145fn check_overrides() -> Option<OverrideMode> {
146    if env::var("FASTAPI_AGENT_MODE").is_ok_and(|v| v == "1") {
147        Some(OverrideMode::ForceAgent)
148    } else if env::var("FASTAPI_HUMAN_MODE").is_ok_and(|v| v == "1") {
149        Some(OverrideMode::ForceHuman)
150    } else {
151        None
152    }
153}
154
155/// Check for FORCE_COLOR override.
156fn force_color_enabled() -> bool {
157    env::var("FORCE_COLOR").is_ok_and(|v| v != "0")
158}
159
160/// Check for known agent environment variables.
161fn check_agent_vars() -> (bool, Option<String>) {
162    for var in AGENT_ENV_VARS {
163        if env::var(var).is_ok() {
164            return (true, Some((*var).to_string()));
165        }
166    }
167    (false, None)
168}
169
170/// Check for known CI environment variables.
171fn check_ci_vars() -> (bool, Option<String>) {
172    for var in CI_ENV_VARS {
173        if env::var(var).is_ok() {
174            return (true, Some((*var).to_string()));
175        }
176    }
177    (false, None)
178}
179
180/// Return user preference based on detection.
181#[must_use]
182pub fn detected_preference() -> OutputPreference {
183    let result = detect_environment();
184    if result.is_agent {
185        OutputPreference::Plain
186    } else {
187        OutputPreference::Rich
188    }
189}
190
191/// Get detailed diagnostics as a formatted string (for debugging).
192#[must_use]
193pub fn detection_diagnostics() -> String {
194    let result = detect_environment();
195    let force_color = force_color_enabled();
196    format!(
197        "DetectionResult {{ is_agent: {}, detected_agent: {:?}, is_ci: {}, \
198         detected_ci: {:?}, is_tty: {}, no_color_set: {}, force_color_set: {}, \
199         override_mode: {:?} }}",
200        result.is_agent,
201        result.detected_agent,
202        result.is_ci,
203        result.detected_ci,
204        result.is_tty,
205        result.no_color_set,
206        force_color,
207        result.override_mode
208    )
209}
210
211#[cfg(test)]
212#[allow(unsafe_code)]
213mod tests {
214    use super::*;
215    use serial_test::serial;
216    use std::env;
217
218    /// Helper to clean environment before each test.
219    ///
220    /// # Safety
221    ///
222    /// This function modifies environment variables, which is inherently
223    /// unsafe in multi-threaded contexts. We use serial_test to ensure
224    /// tests run sequentially.
225    fn clean_env() {
226        // SAFETY: Tests are run serially via #[serial] attribute
227        unsafe {
228            for var in AGENT_ENV_VARS {
229                env::remove_var(var);
230            }
231            for var in CI_ENV_VARS {
232                env::remove_var(var);
233            }
234            env::remove_var("NO_COLOR");
235            env::remove_var("FORCE_COLOR");
236            env::remove_var("FASTAPI_AGENT_MODE");
237            env::remove_var("FASTAPI_HUMAN_MODE");
238        }
239    }
240
241    /// Helper to run test with clean env, restoring afterwards.
242    fn with_clean_env<F: FnOnce()>(f: F) {
243        clean_env();
244        f();
245        clean_env();
246    }
247
248    /// Helper to set an environment variable safely in tests.
249    ///
250    /// # Safety
251    ///
252    /// Tests are run serially via #[serial] attribute.
253    fn set_env(key: &str, value: &str) {
254        // SAFETY: Tests are run serially via #[serial] attribute
255        unsafe {
256            env::set_var(key, value);
257        }
258    }
259
260    // ========== AGENT DETECTION TESTS ==========
261
262    #[test]
263    #[serial]
264    fn test_claude_code_detection() {
265        with_clean_env(|| {
266            set_env("CLAUDE_CODE", "1");
267            let result = detect_environment();
268            eprintln!("[TEST] Claude Code detection: {result:?}");
269            assert!(result.is_agent, "Should detect Claude Code as agent");
270            assert_eq!(result.detected_agent, Some("CLAUDE_CODE".to_string()));
271        });
272    }
273
274    #[test]
275    #[serial]
276    fn test_codex_cli_detection() {
277        with_clean_env(|| {
278            set_env("CODEX_CLI", "1");
279            let result = detect_environment();
280            eprintln!("[TEST] Codex CLI detection: {result:?}");
281            assert!(result.is_agent, "Should detect Codex CLI as agent");
282            assert_eq!(result.detected_agent, Some("CODEX_CLI".to_string()));
283        });
284    }
285
286    #[test]
287    #[serial]
288    fn test_cursor_session_detection() {
289        with_clean_env(|| {
290            set_env("CURSOR_SESSION", "abc123");
291            let result = detect_environment();
292            eprintln!("[TEST] Cursor detection: {result:?}");
293            assert!(result.is_agent, "Should detect Cursor as agent");
294            assert_eq!(result.detected_agent, Some("CURSOR_SESSION".to_string()));
295        });
296    }
297
298    #[test]
299    #[serial]
300    fn test_aider_session_detection() {
301        with_clean_env(|| {
302            set_env("AIDER_SESSION", "1");
303            let result = detect_environment();
304            eprintln!("[TEST] Aider detection: {result:?}");
305            assert!(result.is_agent, "Should detect Aider as agent");
306        });
307    }
308
309    #[test]
310    #[serial]
311    fn test_generic_agent_mode_detection() {
312        with_clean_env(|| {
313            set_env("AGENT_MODE", "1");
314            let result = detect_environment();
315            eprintln!("[TEST] Generic AGENT_MODE detection: {result:?}");
316            assert!(result.is_agent, "Should detect AGENT_MODE");
317        });
318    }
319
320    #[test]
321    #[serial]
322    fn test_windsurf_detection() {
323        with_clean_env(|| {
324            set_env("WINDSURF_SESSION", "1");
325            let result = detect_environment();
326            eprintln!("[TEST] Windsurf detection: {result:?}");
327            assert!(result.is_agent, "Should detect Windsurf");
328        });
329    }
330
331    #[test]
332    #[serial]
333    fn test_cline_detection() {
334        with_clean_env(|| {
335            set_env("CLINE_SESSION", "1");
336            let result = detect_environment();
337            eprintln!("[TEST] Cline detection: {result:?}");
338            assert!(result.is_agent, "Should detect Cline");
339        });
340    }
341
342    #[test]
343    #[serial]
344    fn test_copilot_agent_detection() {
345        with_clean_env(|| {
346            set_env("COPILOT_AGENT", "1");
347            let result = detect_environment();
348            eprintln!("[TEST] Copilot agent detection: {result:?}");
349            assert!(result.is_agent, "Should detect Copilot agent");
350        });
351    }
352
353    // ========== CI DETECTION TESTS ==========
354
355    #[test]
356    #[serial]
357    fn test_generic_ci_detection() {
358        with_clean_env(|| {
359            set_env("CI", "true");
360            let result = detect_environment();
361            eprintln!("[TEST] Generic CI detection: {result:?}");
362            assert!(result.is_ci, "Should detect CI environment");
363            assert!(result.is_agent, "CI should trigger agent mode");
364        });
365    }
366
367    #[test]
368    #[serial]
369    fn test_github_actions_detection() {
370        with_clean_env(|| {
371            set_env("GITHUB_ACTIONS", "true");
372            let result = detect_environment();
373            eprintln!("[TEST] GitHub Actions detection: {result:?}");
374            assert!(result.is_ci);
375            assert_eq!(result.detected_ci, Some("GITHUB_ACTIONS".to_string()));
376        });
377    }
378
379    #[test]
380    #[serial]
381    fn test_gitlab_ci_detection() {
382        with_clean_env(|| {
383            set_env("GITLAB_CI", "true");
384            let result = detect_environment();
385            eprintln!("[TEST] GitLab CI detection: {result:?}");
386            assert!(result.is_ci);
387        });
388    }
389
390    #[test]
391    #[serial]
392    fn test_jenkins_detection() {
393        with_clean_env(|| {
394            set_env("JENKINS_URL", "http://jenkins.example.com");
395            let result = detect_environment();
396            eprintln!("[TEST] Jenkins detection: {result:?}");
397            assert!(result.is_ci);
398        });
399    }
400
401    // ========== NO_COLOR STANDARD TESTS ==========
402
403    #[test]
404    #[serial]
405    fn test_no_color_detection() {
406        with_clean_env(|| {
407            set_env("NO_COLOR", "1");
408            let result = detect_environment();
409            eprintln!("[TEST] NO_COLOR detection: {result:?}");
410            assert!(result.no_color_set, "Should detect NO_COLOR");
411            assert!(result.is_agent, "NO_COLOR should trigger plain mode");
412        });
413    }
414
415    #[test]
416    #[serial]
417    fn test_no_color_empty_value() {
418        with_clean_env(|| {
419            set_env("NO_COLOR", ""); // Empty but set
420            let result = detect_environment();
421            eprintln!("[TEST] NO_COLOR empty value: {result:?}");
422            assert!(
423                result.no_color_set,
424                "Empty NO_COLOR should still be detected"
425            );
426        });
427    }
428
429    // ========== FORCE_COLOR OVERRIDE TESTS ==========
430
431    #[test]
432    #[serial]
433    fn test_force_color_overrides_ci() {
434        with_clean_env(|| {
435            set_env("CI", "true");
436            set_env("FORCE_COLOR", "1");
437            let result = detect_environment();
438            eprintln!("[TEST] FORCE_COLOR override: {result:?}");
439            assert!(!result.is_agent, "FORCE_COLOR should prefer rich output");
440        });
441    }
442
443    // ========== OVERRIDE TESTS ==========
444
445    #[test]
446    #[serial]
447    fn test_force_agent_mode_override() {
448        with_clean_env(|| {
449            set_env("FASTAPI_AGENT_MODE", "1");
450            let result = detect_environment();
451            eprintln!("[TEST] FASTAPI_AGENT_MODE override: {result:?}");
452            assert!(result.is_agent, "Override should force agent mode");
453            assert_eq!(result.override_mode, Some(OverrideMode::ForceAgent));
454        });
455    }
456
457    #[test]
458    #[serial]
459    fn test_force_human_mode_override() {
460        with_clean_env(|| {
461            // Set agent var but then override to human
462            set_env("CLAUDE_CODE", "1");
463            set_env("FASTAPI_HUMAN_MODE", "1");
464            let result = detect_environment();
465            eprintln!("[TEST] FASTAPI_HUMAN_MODE override: {result:?}");
466            assert!(!result.is_agent, "Override should force human mode");
467            assert_eq!(result.override_mode, Some(OverrideMode::ForceHuman));
468        });
469    }
470
471    #[test]
472    #[serial]
473    fn test_agent_override_takes_precedence() {
474        with_clean_env(|| {
475            // Both overrides set - agent takes precedence
476            set_env("FASTAPI_AGENT_MODE", "1");
477            set_env("FASTAPI_HUMAN_MODE", "1");
478            let result = detect_environment();
479            eprintln!("[TEST] Both overrides set: {result:?}");
480            assert!(result.is_agent, "AGENT_MODE should take precedence");
481        });
482    }
483
484    // ========== OUTPUT PREFERENCE TESTS ==========
485
486    #[test]
487    #[serial]
488    fn test_preference_plain_for_agent() {
489        with_clean_env(|| {
490            set_env("CLAUDE_CODE", "1");
491            let pref = detected_preference();
492            eprintln!("[TEST] Preference for agent: {pref:?}");
493            assert_eq!(pref, OutputPreference::Plain);
494        });
495    }
496
497    #[test]
498    #[serial]
499    fn test_preference_rich_for_human_tty() {
500        with_clean_env(|| {
501            // Note: This test may fail if not run in a TTY
502            // The detection will fall back based on is_tty
503            let result = detect_environment();
504            eprintln!("[TEST] Clean env detection: {result:?}");
505            // We cant guarantee TTY in CI, just log the result
506        });
507    }
508
509    // ========== DIAGNOSTICS TESTS ==========
510
511    #[test]
512    #[serial]
513    fn test_diagnostics_format() {
514        with_clean_env(|| {
515            set_env("CLAUDE_CODE", "1");
516            let diag = detection_diagnostics();
517            eprintln!("[TEST] Diagnostics output: {diag}");
518            assert!(diag.contains("is_agent: true"));
519            assert!(diag.contains("CLAUDE_CODE"));
520        });
521    }
522
523    // ========== EDGE CASE TESTS ==========
524
525    #[test]
526    #[serial]
527    fn test_multiple_agents_first_wins() {
528        with_clean_env(|| {
529            set_env("CLAUDE_CODE", "1");
530            set_env("CODEX_CLI", "1");
531            let result = detect_environment();
532            eprintln!("[TEST] Multiple agents: {result:?}");
533            assert!(result.is_agent);
534            // First one in list wins
535            assert_eq!(result.detected_agent, Some("CLAUDE_CODE".to_string()));
536        });
537    }
538
539    #[test]
540    #[serial]
541    fn test_ci_and_agent_both_detected() {
542        with_clean_env(|| {
543            set_env("CLAUDE_CODE", "1");
544            set_env("CI", "true");
545            let result = detect_environment();
546            eprintln!("[TEST] Agent + CI: {result:?}");
547            assert!(result.is_agent);
548            assert!(result.is_ci);
549            assert!(result.detected_agent.is_some());
550            assert!(result.detected_ci.is_some());
551        });
552    }
553
554    #[test]
555    #[serial]
556    fn test_clean_environment() {
557        with_clean_env(|| {
558            let result = detect_environment();
559            eprintln!("[TEST] Clean environment: {result:?}");
560            assert!(result.detected_agent.is_none());
561            assert!(result.detected_ci.is_none());
562            assert!(!result.no_color_set);
563            assert!(result.override_mode.is_none());
564            // is_agent depends on TTY status
565        });
566    }
567}