Skip to main content

sharepoint_cli/
output.rs

1//! Output configuration: TTY detection, JSON/table/quiet modes,
2//! color, and the structured error contract.
3
4use std::io::IsTerminal;
5use std::sync::atomic::{AtomicBool, Ordering};
6
7use serde_json::json;
8
9use crate::error::{CliError, exit_code_for, kind_for};
10
11pub fn use_color() -> bool {
12    !NO_COLOR.load(Ordering::Relaxed)
13        && std::env::var_os("NO_COLOR").is_none()
14        && std::io::stdout().is_terminal()
15}
16
17static NO_COLOR: AtomicBool = AtomicBool::new(false);
18
19pub fn set_no_color(disabled: bool) {
20    NO_COLOR.store(disabled, Ordering::Relaxed);
21}
22
23pub fn terminal_width() -> usize {
24    terminal_size::terminal_size()
25        .map(|(w, _)| w.0 as usize)
26        .unwrap_or(80)
27}
28
29/// Three-valued output format flag (mirrors `--output auto|text|json`).
30#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
31pub enum OutputFormat {
32    /// JSON when stdout is not a TTY; human-friendly text when it is.
33    Auto,
34    /// Always human-friendly text (no JSON even when piped).
35    Text,
36    /// Always JSON.
37    Json,
38}
39
40#[derive(Clone, Copy, Debug)]
41pub struct OutputConfig {
42    /// Whether to emit JSON on stdout for data output.
43    pub json: bool,
44    pub quiet: bool,
45}
46
47impl OutputConfig {
48    /// Build from the `--output` enum and `--quiet` flag.
49    ///
50    /// `--output auto` (the default) emits JSON when stdout is not a TTY.
51    /// An explicit `text` or `json` always wins.
52    pub fn new(format: OutputFormat, quiet: bool) -> Self {
53        let json = match format {
54            OutputFormat::Json => true,
55            OutputFormat::Text => false,
56            OutputFormat::Auto => !std::io::stdout().is_terminal(),
57        };
58        Self { json, quiet }
59    }
60
61    /// Print one line of data to stdout.
62    pub fn print_data(&self, data: &str) {
63        println!("{data}");
64    }
65
66    /// Print informational message to stderr; suppressed by --quiet.
67    pub fn print_message(&self, msg: &str) {
68        if !self.quiet {
69            eprintln!("{msg}");
70        }
71    }
72
73    /// Print an interactive prompt that the user MUST see to proceed.
74    ///
75    /// Device-code prompts (verification URL, user code) are interactive
76    /// instructions, not optional status messages. They are emitted
77    /// unconditionally to stderr regardless of `--quiet` or `--json`.
78    /// Stderr is used even in `--json` mode to keep the JSON stdout stream
79    /// clean and parseable by agents.
80    pub fn print_required_prompt(&self, msg: &str) {
81        eprintln!("{msg}");
82    }
83
84    /// Print serialized JSON to stdout.
85    pub fn print_json(&self, value: &serde_json::Value) {
86        println!(
87            "{}",
88            serde_json::to_string_pretty(value).expect("serialize JSON")
89        );
90    }
91
92    /// Render a structured error.
93    ///
94    /// Machine-readable mode writes a one-line JSON envelope to stderr; text
95    /// mode writes one human-readable error. Stdout remains data-only.
96    ///
97    /// Returns the exit code the caller should use.
98    pub fn render_error(&self, err: &CliError) -> i32 {
99        let exit = exit_code_for(err);
100        let kind = kind_for(err);
101        let envelope = json!({
102            "error": {
103                "kind": kind,
104                "message": err.to_string(),
105                "exit_code": exit,
106            }
107        });
108        if self.json {
109            eprintln!(
110                "{}",
111                serde_json::to_string(&envelope).expect("serialize error envelope")
112            );
113        } else {
114            eprintln!("error: {err}");
115        }
116        exit
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn json_forced_on_when_not_tty() {
126        // Tests run without a TTY, so auto format should still set json=true.
127        let cfg = OutputConfig::new(OutputFormat::Auto, false);
128        assert!(cfg.json);
129    }
130
131    #[test]
132    fn explicit_text_wins_over_auto() {
133        let cfg = OutputConfig::new(OutputFormat::Text, false);
134        assert!(!cfg.json, "text format must not emit JSON even when piped");
135    }
136
137    #[test]
138    fn explicit_json_wins_over_auto() {
139        let cfg = OutputConfig::new(OutputFormat::Json, false);
140        assert!(cfg.json);
141    }
142
143    #[test]
144    fn quiet_flag_propagates() {
145        let cfg = OutputConfig::new(OutputFormat::Auto, true);
146        assert!(cfg.quiet);
147    }
148
149    #[test]
150    fn render_error_returns_input_exit_for_input_error() {
151        let cfg = OutputConfig {
152            json: true,
153            quiet: true,
154        };
155        let exit = cfg.render_error(&CliError::Input("bad ref".into()));
156        assert_eq!(exit, 2);
157    }
158
159    #[test]
160    fn render_error_returns_auth_exit_for_auth_error() {
161        let cfg = OutputConfig {
162            json: true,
163            quiet: true,
164        };
165        let exit = cfg.render_error(&CliError::Auth("expired".into()));
166        assert_eq!(exit, 3);
167    }
168
169    #[test]
170    fn use_color_respects_no_color_env() {
171        // Even with TTY, NO_COLOR=1 should disable color. Tests have no TTY,
172        // so we're really asserting the function returns false either way.
173        // SAFETY: setting env vars in tests is racy; this single-threaded
174        // assertion is safe because we only read inside this block.
175        unsafe { std::env::set_var("NO_COLOR", "1") };
176        assert!(!use_color());
177        unsafe { std::env::remove_var("NO_COLOR") };
178    }
179}