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