Skip to main content

fastapi_output/
mode.rs

1//! Output mode selection and switching.
2//!
3//! This module defines the three output modes and provides logic
4//! for selecting the appropriate mode based on environment detection.
5
6use crate::detection::{OutputPreference, detected_preference};
7use std::str::FromStr;
8
9/// Output rendering mode.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum OutputMode {
12    /// Full rich_rust styling: colors, boxes, tables, unicode.
13    Rich,
14
15    /// Plain text output with no ANSI codes or special characters.
16    #[default]
17    Plain,
18
19    /// Colors only, no box characters or complex formatting.
20    Minimal,
21}
22
23impl OutputMode {
24    /// Get the mode name as a static string.
25    ///
26    /// Returns one of: `"rich"`, `"plain"`, or `"minimal"`.
27    #[must_use]
28    pub const fn as_str(&self) -> &'static str {
29        match self {
30            Self::Rich => "rich",
31            Self::Plain => "plain",
32            Self::Minimal => "minimal",
33        }
34    }
35
36    /// Check if this mode is agent-friendly (produces output suitable for parsing).
37    ///
38    /// Returns `true` for `Plain` mode, which uses no ANSI codes and
39    /// consistent text prefixes like `[OK]`, `[ERROR]`, etc.
40    ///
41    /// # Example
42    ///
43    /// ```rust
44    /// use fastapi_output::OutputMode;
45    ///
46    /// assert!(OutputMode::Plain.is_agent_friendly());
47    /// assert!(!OutputMode::Rich.is_agent_friendly());
48    /// assert!(!OutputMode::Minimal.is_agent_friendly());
49    /// ```
50    #[must_use]
51    pub const fn is_agent_friendly(&self) -> bool {
52        matches!(self, Self::Plain)
53    }
54
55    /// Select the appropriate mode based on environment detection.
56    #[must_use]
57    pub fn auto() -> Self {
58        if let Ok(mode_str) = std::env::var("FASTAPI_OUTPUT_MODE") {
59            if let Ok(mode) = mode_str.parse::<OutputMode>() {
60                if matches!(mode, OutputMode::Rich) {
61                    #[cfg(feature = "rich")]
62                    {
63                        return OutputMode::Rich;
64                    }
65                    #[cfg(not(feature = "rich"))]
66                    {
67                        return OutputMode::Plain;
68                    }
69                }
70                return mode;
71            }
72        }
73
74        match detected_preference() {
75            OutputPreference::Plain => OutputMode::Plain,
76            OutputPreference::Rich => {
77                #[cfg(feature = "rich")]
78                {
79                    OutputMode::Rich
80                }
81                #[cfg(not(feature = "rich"))]
82                {
83                    OutputMode::Plain
84                }
85            }
86        }
87    }
88
89    /// Check if this mode uses ANSI color codes.
90    #[must_use]
91    pub const fn uses_colors(&self) -> bool {
92        matches!(self, Self::Rich | Self::Minimal)
93    }
94
95    /// Check if this mode uses box-drawing characters.
96    #[must_use]
97    pub const fn uses_boxes(&self) -> bool {
98        matches!(self, Self::Rich)
99    }
100
101    /// Check if this mode supports tables.
102    #[must_use]
103    pub const fn supports_tables(&self) -> bool {
104        matches!(self, Self::Rich)
105    }
106
107    /// Get the status indicator for success in this mode.
108    #[must_use]
109    pub const fn success_indicator(&self) -> &'static str {
110        match self {
111            Self::Rich => "✓",
112            Self::Plain | Self::Minimal => "[OK]",
113        }
114    }
115
116    /// Get the status indicator for errors in this mode.
117    #[must_use]
118    pub const fn error_indicator(&self) -> &'static str {
119        match self {
120            Self::Rich => "✗",
121            Self::Plain | Self::Minimal => "[ERROR]",
122        }
123    }
124
125    /// Get the status indicator for warnings in this mode.
126    #[must_use]
127    pub const fn warning_indicator(&self) -> &'static str {
128        match self {
129            Self::Rich => "⚠",
130            Self::Plain | Self::Minimal => "[WARN]",
131        }
132    }
133
134    /// Get the status indicator for info in this mode.
135    #[must_use]
136    pub const fn info_indicator(&self) -> &'static str {
137        match self {
138            Self::Rich => "ℹ",
139            Self::Plain | Self::Minimal => "[INFO]",
140        }
141    }
142
143    /// Check if this mode uses ANSI escape codes.
144    #[must_use]
145    pub const fn uses_ansi(&self) -> bool {
146        self.uses_colors()
147    }
148
149    /// Check if this mode is minimal.
150    #[must_use]
151    pub const fn is_minimal(&self) -> bool {
152        matches!(self, Self::Minimal)
153    }
154}
155
156impl std::fmt::Display for OutputMode {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        match self {
159            Self::Rich => write!(f, "rich"),
160            Self::Plain => write!(f, "plain"),
161            Self::Minimal => write!(f, "minimal"),
162        }
163    }
164}
165
166impl FromStr for OutputMode {
167    type Err = OutputModeParseError;
168
169    fn from_str(s: &str) -> Result<Self, Self::Err> {
170        let normalized = s.trim().to_ascii_lowercase();
171        match normalized.as_str() {
172            "rich" => Ok(Self::Rich),
173            "plain" => Ok(Self::Plain),
174            "minimal" => Ok(Self::Minimal),
175            _ => Err(OutputModeParseError(s.to_string())),
176        }
177    }
178}
179
180/// Check if rich output support is compiled in.
181#[must_use]
182pub const fn has_rich_support() -> bool {
183    cfg!(feature = "rich")
184}
185
186/// Get a human-readable description of available output features.
187#[must_use]
188pub fn feature_info() -> &'static str {
189    if cfg!(feature = "full") {
190        "full (rich output with syntax highlighting)"
191    } else if cfg!(feature = "rich") {
192        "rich (styled output with tables and panels)"
193    } else {
194        "plain (text only, no dependencies)"
195    }
196}
197
198/// Error returned when parsing an invalid output mode string.
199#[derive(Debug, Clone)]
200pub struct OutputModeParseError(String);
201
202impl std::fmt::Display for OutputModeParseError {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        write!(
205            f,
206            "invalid output mode '{}', expected: rich, plain, minimal",
207            self.0
208        )
209    }
210}
211
212impl std::error::Error for OutputModeParseError {}
213
214#[cfg(test)]
215#[allow(unsafe_code)]
216mod tests {
217    use super::*;
218    use serial_test::serial;
219    use std::env;
220
221    fn clean_env() {
222        // SAFETY: Tests are run serially via #[serial] attribute.
223        unsafe {
224            env::remove_var("FASTAPI_OUTPUT_MODE");
225            env::remove_var("FASTAPI_AGENT_MODE");
226            env::remove_var("FASTAPI_HUMAN_MODE");
227            env::remove_var("CLAUDE_CODE");
228            env::remove_var("FORCE_COLOR");
229            env::remove_var("NO_COLOR");
230            env::remove_var("CI");
231        }
232    }
233
234    fn with_clean_env<F: FnOnce()>(f: F) {
235        clean_env();
236        f();
237        clean_env();
238    }
239
240    fn set_env(key: &str, value: &str) {
241        // SAFETY: Tests are run serially via #[serial] attribute.
242        unsafe {
243            env::set_var(key, value);
244        }
245    }
246
247    // ========== ENUM BASIC TESTS ==========
248
249    #[test]
250    fn test_output_mode_default() {
251        let mode = OutputMode::default();
252        eprintln!("[TEST] Default OutputMode: {mode:?}");
253        assert_eq!(mode, OutputMode::Plain);
254    }
255
256    #[test]
257    fn test_output_mode_clone_copy() {
258        let mode = OutputMode::Rich;
259        let cloned = mode;
260        let copied = mode;
261        eprintln!(
262            "[TEST] Clone/Copy test: original={mode:?}, cloned={cloned:?}, copied={copied:?}"
263        );
264        assert_eq!(mode, cloned);
265        assert_eq!(mode, copied);
266    }
267
268    #[test]
269    fn test_output_mode_equality() {
270        assert_eq!(OutputMode::Rich, OutputMode::Rich);
271        assert_eq!(OutputMode::Plain, OutputMode::Plain);
272        assert_eq!(OutputMode::Minimal, OutputMode::Minimal);
273        assert_ne!(OutputMode::Rich, OutputMode::Plain);
274        assert_ne!(OutputMode::Plain, OutputMode::Minimal);
275    }
276
277    // ========== DISPLAY TESTS ==========
278
279    #[test]
280    fn test_display_rich() {
281        let s = OutputMode::Rich.to_string();
282        eprintln!("[TEST] Display Rich: {s}");
283        assert_eq!(s, "rich");
284    }
285
286    #[test]
287    fn test_display_plain() {
288        let s = OutputMode::Plain.to_string();
289        eprintln!("[TEST] Display Plain: {s}");
290        assert_eq!(s, "plain");
291    }
292
293    #[test]
294    fn test_display_minimal() {
295        let s = OutputMode::Minimal.to_string();
296        eprintln!("[TEST] Display Minimal: {s}");
297        assert_eq!(s, "minimal");
298    }
299
300    // ========== FROMSTR TESTS ==========
301
302    #[test]
303    fn test_parse_rich() {
304        let mode: OutputMode = "rich".parse().unwrap();
305        eprintln!("[TEST] Parse rich: {mode:?}");
306        assert_eq!(mode, OutputMode::Rich);
307    }
308
309    #[test]
310    fn test_parse_plain() {
311        let mode: OutputMode = "plain".parse().unwrap();
312        eprintln!("[TEST] Parse plain: {mode:?}");
313        assert_eq!(mode, OutputMode::Plain);
314    }
315
316    #[test]
317    fn test_parse_minimal() {
318        let mode: OutputMode = "minimal".parse().unwrap();
319        eprintln!("[TEST] Parse minimal: {mode:?}");
320        assert_eq!(mode, OutputMode::Minimal);
321    }
322
323    #[test]
324    fn test_parse_case_insensitive() {
325        assert_eq!("RICH".parse::<OutputMode>().unwrap(), OutputMode::Rich);
326        assert_eq!("Plain".parse::<OutputMode>().unwrap(), OutputMode::Plain);
327        assert_eq!(
328            "MINIMAL".parse::<OutputMode>().unwrap(),
329            OutputMode::Minimal
330        );
331        eprintln!("[TEST] Case insensitive parsing works");
332    }
333
334    #[test]
335    fn test_parse_invalid() {
336        let result = "invalid".parse::<OutputMode>();
337        eprintln!("[TEST] Parse invalid: {result:?}");
338        assert!(result.is_err());
339        let err = result.unwrap_err();
340        assert!(err.to_string().contains("invalid"));
341    }
342
343    // ========== CAPABILITY TESTS ==========
344
345    #[test]
346    fn test_uses_colors() {
347        eprintln!(
348            "[TEST] uses_colors: Rich={}, Plain={}, Minimal={}",
349            OutputMode::Rich.uses_colors(),
350            OutputMode::Plain.uses_colors(),
351            OutputMode::Minimal.uses_colors()
352        );
353        assert!(OutputMode::Rich.uses_colors());
354        assert!(!OutputMode::Plain.uses_colors());
355        assert!(OutputMode::Minimal.uses_colors());
356    }
357
358    #[test]
359    fn test_uses_boxes() {
360        eprintln!(
361            "[TEST] uses_boxes: Rich={}, Plain={}, Minimal={}",
362            OutputMode::Rich.uses_boxes(),
363            OutputMode::Plain.uses_boxes(),
364            OutputMode::Minimal.uses_boxes()
365        );
366        assert!(OutputMode::Rich.uses_boxes());
367        assert!(!OutputMode::Plain.uses_boxes());
368        assert!(!OutputMode::Minimal.uses_boxes());
369    }
370
371    #[test]
372    fn test_supports_tables() {
373        eprintln!(
374            "[TEST] supports_tables: Rich={}, Plain={}, Minimal={}",
375            OutputMode::Rich.supports_tables(),
376            OutputMode::Plain.supports_tables(),
377            OutputMode::Minimal.supports_tables()
378        );
379        assert!(OutputMode::Rich.supports_tables());
380        assert!(!OutputMode::Plain.supports_tables());
381        assert!(!OutputMode::Minimal.supports_tables());
382    }
383
384    #[test]
385    fn test_feature_info_matches_flags() {
386        let info = feature_info();
387        eprintln!("[TEST] feature_info: {info}");
388        if cfg!(feature = "full") {
389            assert!(info.contains("full"));
390        } else if cfg!(feature = "rich") {
391            assert!(info.contains("rich"));
392        } else {
393            assert!(info.contains("plain"));
394        }
395    }
396
397    #[test]
398    fn test_has_rich_support_flag() {
399        let expected = cfg!(feature = "rich");
400        eprintln!(
401            "[TEST] has_rich_support: expected={}, actual={}",
402            expected,
403            has_rich_support()
404        );
405        assert_eq!(has_rich_support(), expected);
406    }
407
408    // ========== INDICATOR TESTS ==========
409
410    #[test]
411    fn test_success_indicators() {
412        eprintln!(
413            "[TEST] success_indicator: Rich={}, Plain={}, Minimal={}",
414            OutputMode::Rich.success_indicator(),
415            OutputMode::Plain.success_indicator(),
416            OutputMode::Minimal.success_indicator()
417        );
418        assert_eq!(OutputMode::Rich.success_indicator(), "✓");
419        assert_eq!(OutputMode::Plain.success_indicator(), "[OK]");
420        assert_eq!(OutputMode::Minimal.success_indicator(), "[OK]");
421    }
422
423    #[test]
424    fn test_error_indicators() {
425        eprintln!(
426            "[TEST] error_indicator: Rich={}, Plain={}, Minimal={}",
427            OutputMode::Rich.error_indicator(),
428            OutputMode::Plain.error_indicator(),
429            OutputMode::Minimal.error_indicator()
430        );
431        assert_eq!(OutputMode::Rich.error_indicator(), "✗");
432        assert_eq!(OutputMode::Plain.error_indicator(), "[ERROR]");
433        assert_eq!(OutputMode::Minimal.error_indicator(), "[ERROR]");
434    }
435
436    #[test]
437    fn test_warning_indicators() {
438        eprintln!(
439            "[TEST] warning_indicator: Rich={}, Plain={}, Minimal={}",
440            OutputMode::Rich.warning_indicator(),
441            OutputMode::Plain.warning_indicator(),
442            OutputMode::Minimal.warning_indicator()
443        );
444        assert_eq!(OutputMode::Rich.warning_indicator(), "⚠");
445        assert_eq!(OutputMode::Plain.warning_indicator(), "[WARN]");
446        assert_eq!(OutputMode::Minimal.warning_indicator(), "[WARN]");
447    }
448
449    #[test]
450    fn test_info_indicators() {
451        eprintln!(
452            "[TEST] info_indicator: Rich={}, Plain={}, Minimal={}",
453            OutputMode::Rich.info_indicator(),
454            OutputMode::Plain.info_indicator(),
455            OutputMode::Minimal.info_indicator()
456        );
457        assert_eq!(OutputMode::Rich.info_indicator(), "ℹ");
458        assert_eq!(OutputMode::Plain.info_indicator(), "[INFO]");
459        assert_eq!(OutputMode::Minimal.info_indicator(), "[INFO]");
460    }
461
462    // ========== AUTO DETECTION TESTS ==========
463
464    #[test]
465    #[serial]
466    fn test_auto_explicit_plain_override() {
467        with_clean_env(|| {
468            set_env("FASTAPI_OUTPUT_MODE", "plain");
469            let mode = OutputMode::auto();
470            eprintln!("[TEST] Explicit plain override: {mode:?}");
471            assert_eq!(mode, OutputMode::Plain);
472        });
473    }
474
475    #[test]
476    #[serial]
477    fn test_auto_explicit_minimal_override() {
478        with_clean_env(|| {
479            set_env("FASTAPI_OUTPUT_MODE", "minimal");
480            let mode = OutputMode::auto();
481            eprintln!("[TEST] Explicit minimal override: {mode:?}");
482            assert_eq!(mode, OutputMode::Minimal);
483        });
484    }
485
486    #[test]
487    #[serial]
488    fn test_auto_agent_detected() {
489        with_clean_env(|| {
490            set_env("CLAUDE_CODE", "1");
491            let mode = OutputMode::auto();
492            eprintln!("[TEST] Agent detected mode: {mode:?}");
493            assert_eq!(mode, OutputMode::Plain);
494        });
495    }
496
497    #[test]
498    #[serial]
499    fn test_auto_ci_detected() {
500        with_clean_env(|| {
501            set_env("CI", "true");
502            let mode = OutputMode::auto();
503            eprintln!("[TEST] CI detected mode: {mode:?}");
504            assert_eq!(mode, OutputMode::Plain);
505        });
506    }
507
508    #[test]
509    #[serial]
510    fn test_auto_no_color_detected() {
511        with_clean_env(|| {
512            set_env("NO_COLOR", "1");
513            let mode = OutputMode::auto();
514            eprintln!("[TEST] NO_COLOR detected mode: {mode:?}");
515            assert_eq!(mode, OutputMode::Plain);
516        });
517    }
518
519    #[test]
520    #[serial]
521    fn test_explicit_override_beats_detection() {
522        with_clean_env(|| {
523            set_env("CLAUDE_CODE", "1");
524            set_env("FASTAPI_OUTPUT_MODE", "minimal");
525            let mode = OutputMode::auto();
526            eprintln!("[TEST] Override beats detection: {mode:?}");
527            assert_eq!(mode, OutputMode::Minimal);
528        });
529    }
530
531    #[test]
532    #[serial]
533    fn test_auto_deterministic() {
534        with_clean_env(|| {
535            set_env("CI", "true");
536            let mode1 = OutputMode::auto();
537            let mode2 = OutputMode::auto();
538            let mode3 = OutputMode::auto();
539            eprintln!("[TEST] Deterministic: {mode1:?} == {mode2:?} == {mode3:?}");
540            assert_eq!(mode1, mode2);
541            assert_eq!(mode2, mode3);
542        });
543    }
544
545    // ========== PARSE ERROR TESTS ==========
546
547    #[test]
548    fn test_parse_error_display() {
549        let err = OutputModeParseError("foobar".to_string());
550        let msg = err.to_string();
551        eprintln!("[TEST] Parse error display: {msg}");
552        assert!(msg.contains("foobar"));
553        assert!(msg.contains("rich"));
554        assert!(msg.contains("plain"));
555        assert!(msg.contains("minimal"));
556    }
557
558    #[test]
559    fn test_parse_error_is_error() {
560        let err = OutputModeParseError("x".to_string());
561        let _: &dyn std::error::Error = &err;
562        eprintln!("[TEST] OutputModeParseError implements Error trait");
563    }
564
565    // ========== AS_STR TESTS ==========
566
567    #[test]
568    fn test_as_str_rich() {
569        assert_eq!(OutputMode::Rich.as_str(), "rich");
570    }
571
572    #[test]
573    fn test_as_str_plain() {
574        assert_eq!(OutputMode::Plain.as_str(), "plain");
575    }
576
577    #[test]
578    fn test_as_str_minimal() {
579        assert_eq!(OutputMode::Minimal.as_str(), "minimal");
580    }
581
582    #[test]
583    fn test_as_str_matches_display() {
584        // as_str() should match Display implementation
585        assert_eq!(OutputMode::Rich.as_str(), OutputMode::Rich.to_string());
586        assert_eq!(OutputMode::Plain.as_str(), OutputMode::Plain.to_string());
587        assert_eq!(
588            OutputMode::Minimal.as_str(),
589            OutputMode::Minimal.to_string()
590        );
591    }
592
593    // ========== IS_AGENT_FRIENDLY TESTS ==========
594
595    #[test]
596    fn test_is_agent_friendly_plain() {
597        assert!(OutputMode::Plain.is_agent_friendly());
598    }
599
600    #[test]
601    fn test_is_agent_friendly_rich() {
602        assert!(!OutputMode::Rich.is_agent_friendly());
603    }
604
605    #[test]
606    fn test_is_agent_friendly_minimal() {
607        assert!(!OutputMode::Minimal.is_agent_friendly());
608    }
609
610    #[test]
611    fn test_is_agent_friendly_consistency() {
612        // Plain mode should be the only agent-friendly mode
613        let modes = [OutputMode::Rich, OutputMode::Plain, OutputMode::Minimal];
614        let agent_friendly_count = modes.iter().filter(|m| m.is_agent_friendly()).count();
615        assert_eq!(
616            agent_friendly_count, 1,
617            "Only Plain should be agent-friendly"
618        );
619    }
620}